toga-ai 1.0.810 → 1.0.812

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,7 @@
17
17
  | [1.0 MVC Page Pattern & New-App Skeleton](features/mvc-page-pattern-and-app-skeleton.md) | This is the **reusable recipe for standing up a new 1.0 (`App_`) application** and for adding pages to one — the folder-based MVC routing, the page lifecycle, t |
18
18
  | [NetSuite File Cabinet Content Retrieval via RESTlet (fetchInvoiceFile)](features/netsuite-filecabinet-restlet.md) | How 1.0 pulls **File Cabinet binary content** (invoice PDFs) out of NetSuite over REST. |
19
19
  | [assetType from NetSuite itemtype during Item Sync (opt-in per client)](features/netsuite-item-assettype-sync.md) | `getCreateItem()` never sent `assetType`, so **every item the NetSuite importer created had `Items.assetTypeId = NULL`** — for every client, since the importer |
20
+ | [NetSuite classification → ItemClasses sync (opt-in per client)](features/netsuite-item-class-sync.md) | `App_Api_Toga2::getCreateItemClass()` mirrors NetSuite's **classification tree** into the client's `ItemClasses` table and stamps `Items.itemClassId`. |
20
21
  | [isFulfillable from NetSuite during Item Sync (Phase 1)](features/netsuite-item-isfulfillable-sync.md) | This is the **1.0 (Phase 1)** half of the `isFulfillable` feature: reading the NetSuite `isfulfillable` flag during item sync and stamping it onto the **Agilant |
21
22
  | [NetSuite SuiteQL/REST API Reference](features/netsuite-suiteql-api-reference.md) | General working reference for the Agilant NetSuite integration: how to authenticate, how SuiteQL behaves, and the confirmed schema of the tables/columns/codes w |
22
23
  | [NetSuite SuiteQL/REST Shim — Field Semantics](features/netsuite-suiteql-rest-shim.md) | `App_Api_Netsuite_Rest` is the REST/SuiteQL replacement for the deprecated NetSuite SOAP toolkit. |
@@ -0,0 +1,150 @@
1
+ ---
2
+ title: NetSuite classification → ItemClasses sync (opt-in per client)
3
+ framework: "1.0"
4
+ repo: library
5
+ project: Library
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-09-15
10
+ owners: [rgirish]
11
+ files:
12
+ - library/app/api/toga2.php
13
+ - worker/crons/toga2/netsuite/sync_togasupply_elite.php
14
+ - dbchanges2/_modules/netsuite/2026-09-11a - ItemClassesNetsuiteHierarchy.sql
15
+ - dbchanges2/_modules/netsuite/2026-09-11b - ItemClassApiWritePermission.sql
16
+ - dbchanges2/_modules/netsuite/2026-09-11c - ItemClassRecordPermission.sql
17
+ related:
18
+ - netsuite-item-assettype-sync.md
19
+ - netsuite-item-isfulfillable-sync.md
20
+ - toga2-api-client-and-bridge.md
21
+ - ../../worker/features/netsuite-togasupply-per-client-sync.md
22
+ - ../../../2.0/apps/_underscore/features/item-classification-itemclasses.md
23
+ - ../../../2.0/apps/_underscore/features/acl-permission-chain.md
24
+ - ../../../2.0/apps/dbchanges2/features/rerunnable-additive-inserts.md
25
+ - ../../../clients/elite/features/netsuite-togasupply-sync.md
26
+ ---
27
+
28
+ ## Summary
29
+
30
+ `App_Api_Toga2::getCreateItemClass()` mirrors NetSuite's **classification tree** into the client's
31
+ `ItemClasses` table and stamps `Items.itemClassId`. It is **opt-in per client** via
32
+ `IS_ENABLED_NETSUITE_ITEM_CLASSIFICATIONS`, and today **only Elite** has it on.
33
+
34
+ It is the third flag stamped by the item importer, alongside
35
+ [assetType](netsuite-item-assettype-sync.md) and [isFulfillable](netsuite-item-isfulfillable-sync.md).
36
+ Unlike those two it **creates rows** rather than linking to a hand-curated lookup, so it needs a
37
+ schema migration and a full ACL chain before it can run.
38
+
39
+ **Do not repurpose `ItemClasses` without reading
40
+ [who already uses it](../../../2.0/apps/_underscore/features/item-classification-itemclasses.md)** —
41
+ Northwell (27 rows, ServiceNow categories) and Prudential (7 rows, device tiers) already hold
42
+ unrelated data in that table.
43
+
44
+ ## Key files / entry points
45
+
46
+ - **`library/app/api/toga2.php` → `getCreateItemClass()`** — the whole feature. Returns an
47
+ `ItemClasses.uuid` or `null`.
48
+ - **`App_Api_Netsuite_Rest::listClassifications()`** — pulls the full NetSuite tree (311 rows as of
49
+ 2026-09-11).
50
+ - **`worker/crons/toga2/netsuite/sync_togasupply_elite.php:65`** — the only launcher that turns it on.
51
+
52
+ ## How it works
53
+
54
+ 1. **Gated first.** If `IS_ENABLED_NETSUITE_ITEM_CLASSIFICATIONS` is undefined or false it returns
55
+ `null` immediately, so the `itemClass` key never reaches the item payload.
56
+ 2. **Caches the NetSuite tree once per run** into a `static` map keyed by `internalId`.
57
+ 3. **Caches the client's existing rows once per run** via paged `GET /item-classes`, keyed by
58
+ `c_netsuiteInternalClassId`.
59
+ 4. **Climbs to the root** collecting the ancestor chain, then **creates from the root down** so each
60
+ parent exists before its child's `parentItemClass` references it.
61
+ 5. Each `POST /item-classes` sends `name` + `c_netsuiteInternalClassId` (+ `parentItemClass.uuid`).
62
+
63
+ **Matching is on the NetSuite internal id, never on name.** NetSuite classification names repeat
64
+ across branches ("Shipping" appears 3x), which is why
65
+ `2026-09-11a - ItemClassesNetsuiteHierarchy.sql` **drops the `name` UNIQUE index** and adds a UNIQUE
66
+ on `c_netsuiteInternalClassId` instead.
67
+
68
+ ### Two cache guards worth keeping
69
+
70
+ - **A loaded-flag, not `empty()`.** `ItemClasses` starts empty on every client, so an `empty()` check
71
+ would re-fetch the whole list on every item until the first class exists.
72
+ - **`recordsPerPage => 1000` is explicit on purpose.** An unpaginated V2 list GET **silently
73
+ truncates at 25 rows** — with the default the lookup would miss existing classes and try to
74
+ re-create them.
75
+
76
+ ## ⚠ The field name is `c_netsuiteInternalClassId` — no `Item` in the middle
77
+
78
+ The single trap in this feature, and it broke the whole thing on first deploy.
79
+
80
+ The migration and the live DB column are **`c_netsuiteInternalClassId`**. The code shipped in
81
+ `37b55595` used **`c_netsuiteInternalItemClassId`** in all 5 places. Symptoms:
82
+
83
+ - Every `POST /item-classes` failed with **`EV-9`** — api2 rejects the *entire* record when one field
84
+ is not writable, and an unregistered field is not writable.
85
+ - The lookup `GET` asked for a field that does not exist, so `$lookupItemClassUuid` never populated.
86
+ - Net effect: `Client_Elite.ItemClasses` held **0 rows** — the feature never worked at all since
87
+ deploy, with no error surfaced to the cron.
88
+
89
+ **The migration file is the authority on a custom-field name; code follows the DB.** The column is
90
+ already live in ~22 client databases, so renaming the column to match the code was never an option.
91
+ Fixed in `70f1129f` (merged PR #883, in `_production`).
92
+
93
+ **Generalise this:** before shipping any `c_` field, grep the `dbchanges2` migration for the exact
94
+ string and diff it against the PHP. A mismatch here is invisible — no 500, no log line, just an empty
95
+ table.
96
+
97
+ ## Required schema + ACL (all three layers)
98
+
99
+ `ItemClasses` is `Core.Records` id **105**, `aclDatabase = CLIENT` — so **every grant lives in the
100
+ client database, not Core**. Records 15, 18, 21 and 105 are all CLIENT.
101
+
102
+ | Layer | Table | What it grants | Migration |
103
+ |---|---|---|---|
104
+ | Record | `AclRecordPermissions` + paired `AclLogicGroups` | roleId 3 create/read/update on record 105 | `2026-09-11c` |
105
+ | Base field | `AclFieldPermissions` | roleId 3 writable on RecordFields 601-605, 2596 | `2026-09-11b` |
106
+ | Custom field | `AclCustomFieldPermissions` | roleId 3 writable on `c_netsuiteInternalClassId` | `2026-09-11a` |
107
+
108
+ RecordFields worth knowing: **605** = `Items.itemClassId`, **2596** =
109
+ `ItemClasses.parentItemClassId`, **2594/2595** = `SalesOrderItems.fulfillmentType` /
110
+ `PurchaseOrderItems.fulfillmentType`.
111
+
112
+ **The record-level layer is the one that gets missed** — field grants alone are not enough, and
113
+ without `AclRecordPermissions` the API role cannot `POST /item-classes` at all. Full mechanics:
114
+ [ACL permission chain](../../../2.0/apps/_underscore/features/acl-permission-chain.md).
115
+
116
+ ## Gotchas
117
+
118
+ - **⚠ `2026-09-11c` has no duplicate guard.** It is a plain `INSERT` into `AclRecordPermissions`, so
119
+ every re-run inserts another row. Production shows **three** rows for recordId 105 / roleId 3 on
120
+ every client checked (Elite: ids 346, 347, 348), and only the first (346) has a paired
121
+ `AclLogicGroups` — Elite has 3 logic groups hanging off it instead of 1. Harmless today (all rows
122
+ grant the same access) but it is noise, and the file should get a `NOT EXISTS` guard on
123
+ `(recordId, roleId)` per
124
+ [re-runnable additive migrations](../../../2.0/apps/dbchanges2/features/rerunnable-additive-inserts.md).
125
+ Those migrations also use `UUID()`, which the team standard forbids.
126
+ - **The opt-in constants are the safety net for other clients.** `getCreateItemClass()` and the
127
+ `fulfillmentType` path both return early without their flag, so the keys are omitted entirely and
128
+ no un-provisioned client can hit `EV-9`. Confirmed by grep across all 19
129
+ `sync_togasupply_*.php` launchers — only Elite defines either constant.
130
+ - **Before another client opts in it needs three things**, not just the shared module: (a) the
131
+ `_modules/netsuite` 2026-09-11 grants — already applied broadly (Canon, Quad, Nychh, Adyen,
132
+ Prudential all have record 105 + fields 601-605, 2596); (b) an Elite-`2026-09-11a`-equivalent
133
+ setting `allowUpdate = 1` on records **21** (Items) and **18** (Purchase order items) — Canon,
134
+ Nychh and Adyen are currently `0` there; (c) the Elite-`2026-09-11b`-equivalent grants on fields
135
+ **2594/2595**.
136
+ - **Existing items are not backfilled.** Like assetType stamping, `getCreateItem()` only runs when a
137
+ transaction line references the item, so already-imported items keep `itemClassId = NULL` until the
138
+ sync re-walks transactions that reference them. That needs a
139
+ [cursor rewind](../../worker/features/netsuite-togasupply-per-client-sync.md).
140
+
141
+ ## Change history
142
+ - 2026-09-15 — Documented the feature from an audit of the deployed code (library `37b55595`, worker
143
+ `6637ae75`). **Found and fixed a field-name mismatch that made the feature a complete no-op since
144
+ deploy:** the PHP sent `c_netsuiteInternalItemClassId` in 5 places while the live column and
145
+ `CustomRecordFields` row (recordId 105) are `c_netsuiteInternalClassId`, so every `POST
146
+ /item-classes` failed `EV-9` and `Client_Elite.ItemClasses` had 0 rows. Fixed in `70f1129f`.
147
+ Verified all three ACL layers are correctly granted for Elite in production, and recorded that the
148
+ two opt-in constants are Elite-only so no other client is exposed. Also recorded that
149
+ `2026-09-11c` is unguarded and has produced 3 duplicate `AclRecordPermissions` rows per client.
150
+ (rgirish)
@@ -32,6 +32,7 @@ related:
32
32
  - ../../library/features/toga2-api-client-and-bridge.md
33
33
  - ../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
34
34
  - ../../../clients/elite/features/netsuite-togasupply-sync.md
35
+ - ../../library/features/netsuite-item-class-sync.md
35
36
  - ../../../clients/compass-usa/features/sales-order-line-renumbering.md
36
37
  ---
37
38
 
@@ -68,7 +69,7 @@ Each section (sales orders, POs, invoices, item receipts, item fulfillments, inv
68
69
  adjustments) uses **adaptive time-windowing** via `startModeIteration()`/`finishModeIteration()`:
69
70
  a window stored as `<seconds>-<STATE>` in the client's `Parameters` table shrinks ÷3 when the
70
71
  prior run was left `RUNNING` (didn't finish) and grows ×3 (capped at
71
- `MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS`, default 432000s = 5 days) when it
72
+ `MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS`, default **864000s = 10 days**) when it
72
73
  finished `IDLE`. The window state is written `RUNNING` at the **start** of each section, so a
73
74
  crash leaves it visibly RUNNING and the next run backs off. `LAST_SYNC_DATETIME_*` advances to
74
75
  the window's upper bound after each successful section.
@@ -76,12 +77,15 @@ the window's upper bound after each successful section.
76
77
  ### The window cap is per-client overridable (2026-08-12)
77
78
 
78
79
  `common_sync_togasupply.php:3` is
79
- `defined('MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS') || define(…, 432000)` — **not** a
80
+ `defined('MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS') || define(…, 864000)` — **not** a
80
81
  `const` — so a wrapper may `const MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS = …;`
81
- *before* its `require_once` to widen its own catch-up window. Only Elite does (2592000 = 30 days,
82
- while backfilling from 2024); the other 17 wrappers inherit the 5-day default. Use this only for a
83
- deep backfill and put the reason in a comment next to it — at 5-day windows on a 5-minute cron,
84
- ~858 days of history takes days of wall-clock to catch up; at 30 days it took ~90 minutes.
82
+ *before* its `require_once` to widen its own catch-up window.
83
+
84
+ **⚠ Re-verified 2026-09-15: the shared default is now `864000` (10 days), and NO wrapper overrides
85
+ it — including Elite.** Elite's 30-day (2592000) backfill override was removed after its 2024
86
+ backfill finished, exactly as the note here said it should be. So today every client runs the same
87
+ 10-day cap, and a deep rewind advances at most 10 days per run (see the resync recipe below). Use an
88
+ override only for a deep backfill, and put the reason in a comment next to it.
85
89
 
86
90
  ### ⚠ A shrinking window is a BUG SIGNAL, not a load signal
87
91
 
@@ -125,8 +129,9 @@ mis-set during the 2026-09-08 Elite incident:
125
129
  - **The cap is `864000` (10 days), not `86400` (1 day).** One zero short is a silent 10× smaller
126
130
  window, not an error.
127
131
 
128
- (`MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS` is per-client overridable — Elite's wrapper sets
129
- 2592000. Reset to the client's own cap, not blindly to 864000.)
132
+ (`MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS` is per-client overridable, but as of 2026-09-15
133
+ **no wrapper overrides it** — Elite's old 2592000 is gone. So `864000` is the right value for every
134
+ client today; re-check the wrapper before assuming it, not the other way round.)
130
135
 
131
136
  ### Per-record resume cursor — checkpoint after EACH record, not window-end (LIVE 2026-09-02)
132
137
 
@@ -514,6 +519,30 @@ stage 5: 289135 was closed at **10:50** and the cursor had already passed **15:2
514
519
  WARNING: **`NETSUITE_LAST_SYNC_CURSOR_ITEM_RECEIPTS` on NYCHH is stale at 2024-08-06**, so re-enabling
515
520
  that feed replays roughly **two years** of receipts. Rewind it forward *before* flipping the toggle.
516
521
 
522
+ #### Full resync recipe — and the 10-day ceiling that makes it a loop (2026-09-15)
523
+
524
+ Rewinding to backfill a newly-stamped field (assetType, itemClass, a `c_` field) is the same
525
+ mechanic, but at depth three details decide whether it works. The six keys, all in the **client**
526
+ database's `Parameters` table:
527
+
528
+ `NETSUITE_LAST_SYNC_CURSOR_` + `SALES_ORDERS` · `PURCHASE_ORDERS` · `INVOICES` · `ITEM_RECEIPTS` ·
529
+ `ITEM_FULFILLMENTS` · `INVENTORY_ADJUSTMENTS`.
530
+
531
+ 1. **The id half must be `0` on a rewind.** The value is `<datetime>|<netsuiteInternalId>` and it is a
532
+ *seek* key — a non-zero id makes the run skip every record at that same datetime whose internal id
533
+ sorts below it. Always write `'2026-01-01 00:00:00|0'`, never carry the old id back.
534
+ 2. **The datetime is on the Eastern clock** (`America/New_York`), not UTC and not the server's. See
535
+ the wrong-clock bugs above — this is the same clock they settled on.
536
+ 3. **Reset the paired execution-mode key at the same time.** `NETSUITE_EXECUTION_MODE_<SECTION>`
537
+ holds `<seconds>-<STATE>`; a prior crash leaves it `RUNNING`, and the next run then **shrinks**
538
+ the window ÷3 instead of using the full cap. Observed live 2026-09-15: Elite's
539
+ `NETSUITE_EXECUTION_MODE_SALES_ORDERS` was stuck at `864000-RUNNING`. Set it to
540
+ `<the client's own cap>-IDLE` — see the manual-reset section above for both halves.
541
+ 4. **One run only advances `MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS`** — 864000s (10 days) by
542
+ default, and **Elite does not override this one**. So a rewind to 1 Jan 2026 needs **~26 runs** to
543
+ catch up. On the 5-minute cron that is fine; on a daily cron it would take ~26 days. **Run the
544
+ wrapper by hand in a loop** rather than waiting for the schedule.
545
+
517
546
  ### Sections are NOT order-dependent — one cursor can be rewound alone
518
547
 
519
548
  Each section's `startModeIteration()` reads only **its own** `Parameters` key, and the PO section
@@ -1624,6 +1653,15 @@ library (or vice versa) crashes GroWrk and Adyen on their next sync run.
1624
1653
  [NetSuite Sync Alert Monitor](../../library/features/netsuite-sync-alert-monitor.md).
1625
1654
 
1626
1655
  ## Change history
1656
+ - 2026-09-15 — Added the **full resync recipe** for a deep cursor rewind (the procedure for
1657
+ backfilling a newly-stamped item field): the six `NETSUITE_LAST_SYNC_CURSOR_<SECTION>` keys live in
1658
+ the **client** DB, the id half **must be `0`** or the seek skips records at the same datetime, the
1659
+ datetime is on the **Eastern** clock, and the paired `NETSUITE_EXECUTION_MODE_<SECTION>` must be
1660
+ reset to `<cap>-IDLE` in the same pass — Elite's `..._SALES_ORDERS` was found stuck at
1661
+ `864000-RUNNING`, which shrinks the next window ÷3. Recorded the **10-day ceiling per run** (Elite
1662
+ does *not* override this one), so a rewind to Jan 2026 needs ~26 runs and should be looped by hand
1663
+ rather than left to the schedule. Found auditing the itemClass/fulfillmentType deploy — see
1664
+ [NetSuite item-class sync](../../library/features/netsuite-item-class-sync.md). (rgirish)
1627
1665
 
1628
1666
  - 2026-09-15 — **Compass SALES_ORDERS unfrozen: a Compass-gated prune runs BEFORE the renumber.**
1629
1667
  `syncSalesOrderFromNetsuite` was throwing *"unresolvable NetSuite<->DB lineNumber conflict"*
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-11
9
+ updated: 2026-09-15
10
10
  owners: ["rgirish"]
11
11
  files:
12
12
  - _underscore/Model/Client/ItemClass.php
@@ -18,6 +18,7 @@ related:
18
18
  - ./record-change-audit-log.md
19
19
  - ../../../../clients/prudential/features/itemclasses-device-tier-data.md
20
20
  - ../../dbchanges2/workflows/client-schema-drift-audit.md
21
+ - ../../../../1.0/apps/library/features/netsuite-item-class-sync.md
21
22
  ---
22
23
 
23
24
  ## Summary
@@ -78,7 +79,13 @@ LIMIT 1
78
79
  That cron is **disabled** — see the
79
80
  [Prudential ItemClasses data doc](../../../../clients/prudential/features/itemclasses-device-tier-data.md).
80
81
 
81
- **Nothing writes `itemClassId`.** No writer exists in `api2`, `worker2`, or `_underscore`.
82
+ **~~Nothing writes `itemClassId`.~~ There is now ONE writer (2026-09-15).** The audit below was
83
+ correct on 2026-09-11; four days later the 1.0 NetSuite importer became the first writer —
84
+ `App_Api_Toga2::getCreateItemClass()` creates `ItemClasses` rows from the NetSuite classification
85
+ tree and stamps `Items.itemClassId`. It is **opt-in per client and only Elite has it on**, so the
86
+ "0 rows for 34 clients" picture below still holds everywhere else. See
87
+ [NetSuite item-class sync](../../../../1.0/apps/library/features/netsuite-item-class-sync.md).
88
+ No writer exists in `api2`, `worker2`, or `_underscore`.
82
89
 
83
90
  ### Frontend: one real reference, and a grep trap
84
91
 
@@ -101,9 +108,12 @@ Queried live via SuiteQL (2026-09-11): **311 classifications**, columns
101
108
 
102
109
  ## Gotchas
103
110
 
104
- - **⚠ `ItemClasses.name` has a UNIQUE index (index literally named `name`).** NetSuite
105
- classification names repeat, so **any attempt to load NetSuite classes into this table fails
106
- until that UNIQUE is dropped.** This is the first blocker on any NetSuite-classification work.
111
+ - **~~⚠ `ItemClasses.name` has a UNIQUE index~~ — DROPPED 2026-09-11.** It was the first blocker on
112
+ NetSuite-classification work exactly as predicted here; `_modules/netsuite/2026-09-11a -
113
+ ItemClassesNetsuiteHierarchy.sql` drops it and adds a UNIQUE on the new
114
+ `c_netsuiteInternalClassId` instead, so matching is by NetSuite internal id and repeated leaf
115
+ names ("Shipping" x3) are fine. That migration also adds the self-referencing
116
+ `parentItemClassId` FK for the tree.
107
117
  - **A hierarchy path column must be `TEXT`, not `varchar(512)`.** NetSuite `fullname` values are
108
118
  long, and the 2.0 standard caps varchar at 255.
109
119
  - **The table is already `utf8mb4_0900_ai_ci`**, so new columns need no explicit `COLLATE`.
@@ -117,6 +127,13 @@ Queried live via SuiteQL (2026-09-11): **311 classifications**, columns
117
127
  would break Northwell's 245 tagged items, which hold ServiceNow service categories.
118
128
 
119
129
  ## Change history
130
+ - 2026-09-15 — **Two facts from this audit are now superseded by the shipped NetSuite item-class
131
+ sync.** (1) "Nothing writes `itemClassId`" — `App_Api_Toga2::getCreateItemClass()` is now the
132
+ first writer, opt-in per client, Elite only. (2) The `ItemClasses.name` UNIQUE index — called out
133
+ here as the first blocker — was **dropped** by `_modules/netsuite/2026-09-11a`, which also added
134
+ `parentItemClassId` (self-FK) and a UNIQUE `c_netsuiteInternalClassId`. The Northwell (27 rows)
135
+ and Prudential (7 rows) warning is unaffected and still governs any reuse of this table.
136
+ (rgirish)
120
137
  - 2026-09-11 — Documented from a full usage audit of `ItemClasses` across every TOGA codebase and
121
138
  all 36 prod client schemas: the table ships blank to all clients but **only Northwell (27 rows,
122
139
  ServiceNow service categories) and Prudential (7 rows, Dell device tiers) have data**, for two
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-08
9
+ updated: 2026-09-15
10
10
  owners: ["bala", "mhammontree", "tcox", "apeterson", "rgirish"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -234,6 +234,16 @@ production `Core.RecordFields` for `Items`:
234
234
  |---|---|---|---|
235
235
  | **107** | `Items.manufacturerId` | `MATCH_UPSERT` | **creates** the manufacturer — get-or-create by name works |
236
236
  | **269** | `Items.assetTypeId` | `MATCH` | **fails the entire item write** |
237
+ | **914** | `Items.catalogId` | `MATCH` | **matches** the existing catalog — and can never create one |
238
+
239
+ Row 914 is the benign shape of `MATCH`, and it is worth knowing why it is benign: the match set is
240
+ built from **`isIdentifier`**, and the `catalogs` record carries `isIdentifier = 1` on **`id`,
241
+ `uuid` AND `name`** (verified on production `Core.RecordFields`, 2026-09-15). So
242
+ `catalog: {name: 'Compass'}` resolves to the existing `Catalogs` row by name and **cannot mint a
243
+ new catalog** — which is what makes a name-keyed catalog safe to seed as a per-tenant default
244
+ (see [surface-layer-schema](../../dbchanges2/features/surface-layer-schema.md)). The identifier set
245
+ is per-record data, not a code constant: check `isIdentifier` before assuming a business key is
246
+ matchable.
237
247
 
238
248
  The 1.0 NetSuite importer's `getCreateItem()` contains **both** calls. `getCreateManufacturer()`
239
249
  sends `manufacturer: {name}` and has always worked; copying that shape for `assetType: {name}`
@@ -442,6 +452,11 @@ only one tenant's copy has the wrong flag.
442
452
 
443
453
  ## Change history
444
454
 
455
+ - 2026-09-15 — Added **`Items.catalogId` (recordFieldId 914, `MATCH`)** to the childPolicy table as
456
+ the *benign* `MATCH` case, and recorded why: the `catalogs` record has `isIdentifier = 1` on
457
+ **`id`, `uuid` and `name`** (prod `Core.RecordFields`), so `catalog: {name: '<catalog>'}` always
458
+ links the existing row and can never create a catalog. That is what lets a tenant ship a
459
+ name-keyed catalog as an item-create default. (bala)
445
460
  - 2026-09-08 — ⚠ Recorded the **third EV-12 flavor: the field is declared in PHP but
446
461
  `isIdentifier = 0` in the tenant's `CustomRecordFields`.** `V2.php` (~L7471-7530) builds
447
462
  `$recordIdentifiers` purely from DB metadata (`Core.RecordFields` + `Client_<X>.CustomRecordFields`
@@ -6,13 +6,14 @@ project: Database Changes
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-09
10
- owners: [bala, kyalamarthi, apeterson, ajean, jcardinal]
9
+ updated: 2026-09-15
10
+ owners: [bala, kyalamarthi, apeterson, ajean, jcardinal, rgirish]
11
11
  files:
12
12
  - dbchanges2/Client_Compass/2026-08-26a - CompassCreativeStudioPersona.sql
13
13
  - dbchanges2/Core/2026-08-27a - Insert - Netsuite Location SyncAll CronJob.sql
14
14
  - dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql
15
15
  - dbchanges2/Core/2026-09-08e - Insert QcBatch CronJob.sql
16
+ - dbchanges2/_modules/netsuite/2026-09-11c - ItemClassRecordPermission.sql
16
17
  related:
17
18
  - ../architecture.md
18
19
  - ../../worker2/features/netsuite-salesorder-open-orders-sync.md
@@ -284,6 +285,20 @@ concluding a migration is production-safe.
284
285
 
285
286
  ## Gotchas
286
287
 
288
+ - **⚠ An unguarded ACL grant migration duplicates silently — `AclRecordPermissions` has no unique key
289
+ that stops it (found in prod 2026-09-15).** `_modules/netsuite/2026-09-11c - ItemClassRecordPermission.sql`
290
+ is a plain `INSERT … VALUES` for `(recordId 105, roleId 3)` with **no `NOT EXISTS` guard**. Every
291
+ client checked in production carries **three** identical rows (Elite: ids 346, 347, 348), and its
292
+ paired `AclLogicGroups` insert uses `LIMIT 1` with no guard either — so Elite ended up with **3
293
+ logic groups on permission 346** and **zero** on 347/348. Nothing errored, and access is still
294
+ correct (every row grants the same thing), which is precisely why it went unnoticed across a whole
295
+ module rollout. Two rules this file breaks and the next one should not:
296
+ - Guard on the real identity, `NOT EXISTS (… WHERE recordId = 105 AND roleId = 3)` — the
297
+ declared unique key is `(appId, recordId, roleId, indirectRecordId)`, and `appId`/
298
+ `indirectRecordId` being NULL means MySQL does **not** enforce it.
299
+ - It uses `UUID()`, which the team standard forbids everywhere (see the UUID4 section above).
300
+ Cleanup is safe but must keep **one** permission row *and* its logic group together — delete the
301
+ childless duplicates (347, 348), not the one that owns the group.
287
302
  - **A "re-run safe" file is usually only re-run safe *per phase*.** If Phase 1 creates the parent row
288
303
  with no `NOT EXISTS` guard, re-running the *whole file* creates a **second parent** and then hangs
289
304
  a full set of children off it. Re-run safety of the child phases does not make the file idempotent.
@@ -318,6 +333,12 @@ concluding a migration is production-safe.
318
333
  [FIELD_SQL calculated fields](../../_underscore/features/calculated-sql-fields.md).
319
334
 
320
335
  ## Change history
336
+ - 2026-09-15 — Added a worked production example of the unguarded-insert trap on an **ACL grant**
337
+ migration: `_modules/netsuite/2026-09-11c` inserts `AclRecordPermissions (105, 3)` with no
338
+ `NOT EXISTS` guard, so every client now has **three** duplicate rows (Elite 346/347/348) and a
339
+ lopsided `AclLogicGroups` fan-out (3 groups on 346, none on 347/348). The declared unique key
340
+ `(appId, recordId, roleId, indirectRecordId)` does **not** protect it because `appId` and
341
+ `indirectRecordId` are NULL. Also flagged the file's use of `UUID()`. (rgirish)
321
342
  - 2026-09-09 — Added the rule that **every hardcoded uuid literal must be generated independently at
322
343
  random** — never bump a digit / copy-and-tweak / pattern them (guessable, defeats uniqueness); the
323
344
  fan-out same-literal reuse is the one intended exception. Restated **never `UUID()`, single-row or
@@ -6,9 +6,12 @@ project: Database Changes
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-04
10
- owners: [jcardinal, apeterson, tcox]
9
+ updated: 2026-09-15
10
+ owners: [jcardinal, apeterson, tcox, bala]
11
11
  files:
12
+ - dbchanges2/Core/2026-09-15a - ItemCreateDefaultsSurfaceSeed.sql
13
+ - dbchanges2/Client_Compass/2026-09-15a - ItemCreateCatalogDefault.sql
14
+ - dbchanges2/Client_CompassCanada/2026-09-15a - ItemCreateCatalogDefault.sql
12
15
  - dbchanges2/Client_Compass/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql
13
16
  - dbchanges2/Client_CompassCanada/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql
14
17
  - dbchanges2/Core/2026-09-02a - TalosAssistantSurfaceSeed.sql
@@ -1372,7 +1375,100 @@ environment and take the **highest** `MAX(id)+1`, never the one in front of you,
1372
1375
  `Core.SurfaceElements.id` = **233**, next `Core.Messages.id` = **299**. Both consuming files were
1373
1376
  still unrun at the time, so this is a reservation, not a deployed state.
1374
1377
 
1378
+ ## A marker element can carry per-tenant WRITE defaults, not just visibility (`item-record-create-defaults`, Core 61, 2026-09-15)
1379
+
1380
+ The marker-element pattern (a hidden element whose `config` is the payload) had so far only carried
1381
+ **visibility / mode** flags. `Core/2026-09-15a - ItemCreateDefaultsSurfaceSeed.sql` uses it to carry
1382
+ **default field VALUES for a create form**: surface **`item-record-create-defaults`** (type
1383
+ `SECTION`, `config.cardType = 'createDefaults'`) with one element, `isVisible = 0`, whose config is
1384
+
1385
+ ```json
1386
+ {"role":"createDefaults","values":{}}
1387
+ ```
1388
+
1389
+ Core seeds `values` **empty** — the true-neutral default — and each tenant opts in with a single
1390
+ `SurfaceOverrides` **`CONFIG`** row. The consuming app spreads `values` into its `POST` body before
1391
+ the form fields, so a default never beats real user input. FE side:
1392
+ [surface-frontend](../../toga25-supply/features/surface-frontend.md). This shipped to fix items
1393
+ created in toga25-supply being written with `Items.catalogId = NULL` (28 rows in prod
1394
+ `Client_Compass`, 6 in `Client_CompassCanada`).
1395
+
1396
+ **Why `name` and not `uuid` in the seeded value.** `Items.catalogId` is `Core.RecordFields` **914**
1397
+ with `childPolicy = MATCH`, and the `catalogs` record has `isIdentifier = 1` on `id`, `uuid` **and**
1398
+ `name` — so a name matches the existing row and **can never create a catalog**
1399
+ ([nested-relationship writes](../../api2/features/nested-relationship-writes.md)). A name also keeps
1400
+ the seed readable and tenant-portable, where a uuid would not be.
1401
+
1402
+ **The two tenants spell the catalog differently, and the seeds use the STORED spelling:**
1403
+
1404
+ | Tenant | Seeded value | `Catalogs` reality |
1405
+ |---|---|---|
1406
+ | `Client_Compass` | `{"catalog":{"name":"Compass"}}` | id 1 **`Compass`** (also id 2 `Agilant`, id 3 `Office Depot`) |
1407
+ | `Client_CompassCanada` | `{"catalog":{"name":"compass"}}` | id 1 **`compass`** — the only row |
1408
+
1409
+ Both `name` columns are case-insensitive (`utf8mb4_unicode_ci` on Compass,
1410
+ `utf8mb4_0900_ai_ci` on Canada), so either spelling would in fact match; matching the stored
1411
+ spelling is a readability rule, not a correctness one. Catalog background:
1412
+ [Compass item catalogs](../../../clients/compass-usa/features/item-catalogs-and-duplicate-items.md).
1413
+
1414
+ ### Both client files restate `role` — the CONFIG-replaces-wholesale rule, hit again
1415
+
1416
+ A `CONFIG` override **replaces the element's config object entirely** (`Surface.php:839`, see
1417
+ [the load-bearing rule](#-a-config-override-replaces-config-wholesale--this-is-the-load-bearing-rule-of-the-whole-design)),
1418
+ so each client row must ship **`role` AND `values`**:
1419
+
1420
+ ```json
1421
+ {"role":"createDefaults","values":{"catalog":{"name":"Compass"}}}
1422
+ ```
1423
+
1424
+ Send only `values` and the element loses its `role`, the adapter stops finding the marker, and the
1425
+ default silently disappears with no error anywhere. This is now the **second** feature to hit it
1426
+ (after the Talos suggestion chips) — treat "restate every key the FE matches on" as the default
1427
+ authoring step for any CONFIG override, not a special case.
1428
+
1429
+ ### The client files hardcode the Core ids — and the 2026-07-20d precedent is NOT copyable
1430
+
1431
+ `Client_Compass/2026-09-15a` and `Client_CompassCanada/2026-09-15a` inline the Core `surfaceId` /
1432
+ `surfaceElementId` read from Core **after** the Core file was run, and guard re-runs with
1433
+ `SELECT … FROM DUAL WHERE NOT EXISTS (…)` against the **client table only** — no `Core.*` read at
1434
+ all.
1435
+
1436
+ **Confirmed again by a real failure this session:** querying `Client_Compass` with a join to `Core`
1437
+ returns `OperationalError (1049, "Unknown database 'Core'")` on the `prod-client` cluster. The
1438
+ nearest existing example, `Client_Compass/2026-07-20d - ItemRecordVendorItemsEnable.sql`, resolves
1439
+ its element id with `FROM Core.SurfaceElements JOIN Core.Surfaces` — **do not copy it.** It is one of
1440
+ the ~51 stale cross-cluster files described above; it landed in prod by hand transposition, not by
1441
+ running as written.
1442
+
1443
+ ### Ids consumed — and beta must be RE-READ, not reused
1444
+
1445
+ `Surfaces`/`SurfaceElements` ids differ sharply per environment: before this work prod Core was at
1446
+ `Surfaces` **60** / `SurfaceElements` **233**, while dev-sandbox (beta) was at **23** / **65**. The
1447
+ developer ran the Core file **on PROD**, taking **surface 61** and **element 234**, and the two
1448
+ client files hardcode those prod ids.
1449
+
1450
+ > ⚠ **Beta has not had the Core file run.** Running the client files on beta as written would point
1451
+ > the overrides at ids that mean something else (or nothing) there. Re-read the beta ids after
1452
+ > running the Core file and swap them in before running the client files.
1453
+
1454
+ **Handoff published (verify before your own run):** next prod `Core.Surfaces.id` = **62**, next prod
1455
+ `Core.SurfaceElements.id` = **235**.
1456
+
1375
1457
  ## Change history
1458
+ - 2026-09-15 — Added the **`item-record-create-defaults`** seed trio (`Core/2026-09-15a` +
1459
+ `Client_Compass` / `Client_CompassCanada` `2026-09-15a - ItemCreateCatalogDefault.sql`), which
1460
+ extends the marker-element pattern from visibility flags to **per-tenant create-form default
1461
+ VALUES** (`config {"role":"createDefaults","values":{}}`, Core seeds `values` empty, each tenant
1462
+ overrides). Shipped to stop toga25-supply writing `Items.catalogId = NULL`. Recorded: the value is
1463
+ seeded by catalog **name** because `RecordFields` 914 is `MATCH` over a record with
1464
+ `isIdentifier` on `name` (so it can never mint a catalog); the two tenants store the name with
1465
+ different case (`Compass` vs `compass`, both CI collations); both client files must restate
1466
+ **`role`** because a CONFIG override replaces the config wholesale (second feature to hit that);
1467
+ and the client files hardcode Core ids with a client-only `NOT EXISTS` guard — re-confirmed by a
1468
+ live `1049 Unknown database 'Core'` on `prod-client`, which also makes the older
1469
+ `2026-07-20d - ItemRecordVendorItemsEnable.sql` a NON-copyable precedent. Prod consumed
1470
+ **Surface 61 / SurfaceElement 234** (beta is at 23 / 65 and has NOT run the Core file — re-read
1471
+ its ids first); next prod ids = **62** / **235**. (bala)
1376
1472
  - 2026-09-04 — Added the **restrict-to-persona opt-in pair** (`Client_Compass/2026-09-04a` +
1377
1473
  `Client_CompassCanada/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql`): client-wide
1378
1474
  `IS_VISIBLE`+`IS_ENABLED` overrides on `surfaceId` **19** / `surfaceElementId` **57**, literal Core
@@ -6,8 +6,8 @@ project: TOGa 2.5 Supply
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-04
10
- owners: [jcardinal, apeterson, tcox, rgirish]
9
+ updated: 2026-09-15
10
+ owners: [jcardinal, apeterson, tcox, rgirish, bala]
11
11
  files:
12
12
  - toga25-supply/src/layout/RecordApprovalModal/helpers/stackedCurrencyJoiner.ts
13
13
  - toga25-supply/src/layout/RecordApprovalModal/helpers/stackedCurrencyJoiner.test.ts
@@ -71,6 +71,7 @@ files:
71
71
  - toga25-supply/src/pages/Inventory/viewModel/FIELDS/index.ts
72
72
  - toga25-supply/src/fieldsConfig/index.ts
73
73
  - toga25-supply/src/layout/ItemRecordModalLayout/helpers/surfaceBundleToItemFields.ts
74
+ - toga25-supply/src/layout/ItemRecordModalLayout/hooks/useItemCreateForm.tsx
74
75
  - toga25-supply/src/layout/ItemRecordModalLayout/helpers/index.ts
75
76
  - toga25-supply/src/layout/ItemRecordModalLayout/viewModel/useItemRecordModalViewModel.tsx
76
77
  - toga25-supply/src/layout/ItemRecordModalLayout/ItemRecordModalLayout.tsx
@@ -402,6 +403,64 @@ a `computeValueKey`, grep the consuming view's registry for that literal string
402
403
  > makes the field visible and therefore **crashes the modal**; the FE alone changes nothing visible.
403
404
  > Same shape as the approvals-gate ship-together dependency.
404
405
 
406
+ ## Item CREATE defaults also come from Surface — and a missing default wrote NULL `catalogId` (2026-09-15)
407
+
408
+ The item modal's **create** payload now takes its per-tenant defaults from the Surface layer instead
409
+ of a client branch in code. This started as a data bug: `useItemCreateForm` built the `POST /items`
410
+ body with **no catalog at all**, so every item created in this app was saved with
411
+ `Items.catalogId = NULL`. The old `toga2-supply` `CreateItemModal.tsx` had always sent
412
+ `catalog: { name: "Compass" }` — which is why only the 2.5 app produced NULL rows, and why nobody
413
+ saw it in the older app. **Measured on prod 2026-09-15:** `Client_Compass.Items` held **28** rows
414
+ with a NULL `catalogId` (newest 2026-09-14) against 1,509 on catalog 1, and
415
+ `Client_CompassCanada.Items` **6** NULL against 221. A NULL-catalog item **never reaches the
416
+ storefront** — see
417
+ [Compass item catalogs](../../../clients/compass-usa/features/item-catalogs-and-duplicate-items.md).
418
+
419
+ **How it works now** — the same marker-element shape as `item-record-vendor-items`, read by a new
420
+ adapter:
421
+
422
+ - New Core surface **`item-record-create-defaults`** (type `SECTION`, `surface.config.cardType =
423
+ 'createDefaults'`) carrying **one non-rendered marker element** with
424
+ `config = {"role":"createDefaults","values":{}}` and `isVisible = 0`.
425
+ - The slug was added to **`ITEM_RECORD_SURFACE_SLUGS`**, so it rides the group fetch the record
426
+ modal already makes — no new request.
427
+ - **`surfaceBundleToItemCreateDefaults()`** (`helpers/surfaceBundleToItemFields.ts`, exported via
428
+ `helpers/index.ts`) finds the marker by `config.role === 'createDefaults'` and returns its
429
+ `config.values` object.
430
+ - `useItemCreateForm` **spreads the defaults FIRST** into the `POST /items` body, so a real form
431
+ field always wins over a default. Order is the whole contract here.
432
+ - A tenant opts in with one `SurfaceOverrides` CONFIG row. Compass seeds
433
+ `{"catalog":{"name":"Compass"}}`; Compass Canada seeds `{"catalog":{"name":"compass"}}` — the
434
+ spelling differs per tenant, see
435
+ [surface-layer-schema](../../dbchanges2/features/surface-layer-schema.md).
436
+
437
+ **Why this is the right seam:** a tenant default is presentation/tenant config, so it belongs in the
438
+ surface layer, not in a hostname or client-slug switch in the create form. Adding the next default
439
+ (vendor, inventory type, anything) is now a `values` key plus a seed row — zero FE change.
440
+
441
+ ### Adding a slug to a group fetch is SAFE on unseeded clients and environments
442
+
443
+ `_buildBundle` returns `{ surface: null, elements: [] }` when `$surface->load()` fails, so a slug
444
+ that has not been seeded in a tenant (or a whole environment where the Core file has not run) simply
445
+ resolves to an empty bundle. The adapter then returns `{}` and the create payload is byte-identical
446
+ to before. Verified against `Model/Core/Surface.php` — see
447
+ [surface-resolver](../../_underscore/features/surface-resolver.md). **So the FE can ship before the
448
+ SQL runs anywhere** — the reverse of the restrict-to-persona deploy-order rule above, because this
449
+ element renders nothing.
450
+
451
+ ### Sending `catalog` by NAME does not trip the Compass POST interceptor
452
+
453
+ `_Model_Compass_Item::postPost` throws *"Could not load catalog uuid"* when it receives a catalog
454
+ with no uuid, which looks like a reason to send a uuid instead of a name. It is not. api2 invokes the
455
+ POST payload interceptor with **`$outData` — the created record, not the submitted body**
456
+ (`V2.php:6019`), and the created record always carries the resolved catalog uuid. Compass has an
457
+ active POST/POST interceptor on `recordId 21` (Items); Compass Canada has one too, but there is no
458
+ `_Model_CompassCanada_Item`, so it falls back to `_Model_Client_Item::postPost`, which returns early
459
+ unless `isFulfillable` was sent. Background:
460
+ [API payload interceptors](../../api2/features/api-payload-interceptors.md). The name→row match
461
+ itself is safe because `Items.catalogId` is a `MATCH` field over a record whose `name` is an
462
+ identifier — [nested-relationship writes](../../api2/features/nested-relationship-writes.md).
463
+
405
464
  ## SalesOrders approval-decision modal (approve + deny) — migrated via an OVERLAY adapter seam
406
465
 
407
466
  The SalesOrder approve/deny decision modal's config now sources its display fields from Surface,
@@ -1100,6 +1159,17 @@ claim is about the `navigation-*` **flags** being inert — still true — not a
1100
1159
  unused).
1101
1160
 
1102
1161
  ## Change history
1162
+ - 2026-09-15 — **Item create defaults moved to the Surface layer, fixing NULL `catalogId` on every
1163
+ item created in this app.** `useItemCreateForm` sent no catalog at all (prod: 28 NULL-catalog rows
1164
+ in `Client_Compass`, 6 in `Client_CompassCanada`; the older `toga2-supply` modal had always sent
1165
+ `catalog: {name:"Compass"}`), and a NULL-catalog item never reaches the storefront. New Core
1166
+ surface `item-record-create-defaults` carries a hidden `config.role='createDefaults'` marker whose
1167
+ `config.values` each tenant overrides; the slug rides the existing `ITEM_RECORD_SURFACE_SLUGS`
1168
+ group fetch, `surfaceBundleToItemCreateDefaults()` adapts it, and the create form spreads it
1169
+ **first** so form fields still win. Recorded two rules that make this cheap: an unseeded slug
1170
+ resolves to an empty bundle (so FE can ship before the SQL, unlike the restrict-to-persona case),
1171
+ and sending `catalog` by **name** cannot trip `_Model_Compass_Item::postPost` because api2 hands a
1172
+ POST interceptor the created record, not the request body. (bala)
1103
1173
  - 2026-09-04 — Fixed the item modal's **More Info → "Restrict to Persona"** field missing in VIEW for
1104
1174
  Compass USA + Canada. Two causes, both now rules here: (1) the item modal's VIEW is Surface-driven
1105
1175
  while EDIT is still JSON, so a Core-default-OFF element with no client `SurfaceOverride` vanishes
@@ -4,7 +4,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
4
4
 
5
5
  ## 1.0 framework
6
6
 
7
- - **library** (Library) _(framework core)_ — 25 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
7
+ - **library** (Library) _(framework core)_ — 26 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
8
  - **worker** (Worker) — 35 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
9
9
  - **dbchanges** (Database Changes) _(framework core)_ — 1 doc(s) → [1.0/apps/dbchanges/INDEX.md](1.0/apps/dbchanges/INDEX.md)
10
10
  - **worker1.5** (Worker 1.5) — 0 doc(s) → [1.0/apps/worker1.5/INDEX.md](1.0/apps/worker1.5/INDEX.md)
@@ -17,7 +17,7 @@ project: _Underscore
17
17
  client: compass-canada
18
18
  type: profile
19
19
  status: active
20
- updated: 2026-09-10
20
+ updated: 2026-09-15
21
21
  owners: [jcardinal, bala, tcox, apeterson, ajean]
22
22
  files: []
23
23
  related:
@@ -61,6 +61,13 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
61
61
  resolution + client constants that all 8 Canada crons depend on. `library` is in `apps` for that
62
62
  reason.
63
63
 
64
+ - **Catalogs: one row, lowercase.** `Client_CompassCanada.Catalogs` holds a **single** row, id 1
65
+ name **`compass`** (lowercase) — not the `Compass` / `Agilant` / `Office Depot` trio Compass USA
66
+ has ([item catalogs](../compass-usa/features/item-catalogs-and-duplicate-items.md)). The column
67
+ collation is case-insensitive, but write the stored spelling when seeding a value that references
68
+ it by name. Items created in `toga25-supply` were saved with a NULL `catalogId` until 2026-09-15
69
+ (6 rows here); the fix seeds the catalog as a Surface create-default per tenant.
70
+
64
71
  ## Vendors & integrations
65
72
  - **Grand & Toy (G&T)** — primary hardware vendor. SOs flow toga → MITS → PO to G&T; G&T sends
66
73
  back ASNs. ASN ingestion (email CSV + the auto-created ItemFulfillment chain + bilingual
@@ -6,7 +6,7 @@ project: Library
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-02
9
+ updated: 2026-09-15
10
10
  owners: ["bala", "jcardinal"]
11
11
  files:
12
12
  - library/app/api/toga2.php
@@ -214,6 +214,15 @@ children" rule:
214
214
  - **⚠ A NULL `catalogId` is a real state** (27 items in prod, 22 ODP `VendorItems` point at such
215
215
  items). Anything joining through `Catalogs` **drops those rows silently** — an inner join to
216
216
  `Catalogs` is itself a filter.
217
+ **Where the newest NULL rows came from is now known, and it is fixed (2026-09-15):** the
218
+ `toga25-supply` item **create** form posted no catalog at all, so every item created in the 2.5 app
219
+ was born with `catalogId = NULL` — **28** such rows in `Client_Compass` (newest 2026-09-14) and
220
+ **6** in `Client_CompassCanada`, against 1,509 / 221 on catalog 1. A NULL-catalog item never
221
+ reaches the storefront. The create form now takes `catalog: {name: 'Compass'}` from a seeded
222
+ Surface default instead of hardcoding it (the older `toga2-supply` modal always sent it, which is
223
+ why the NULL rows are all from the new app) — see
224
+ [surface-frontend](../../../2.0/apps/toga25-supply/features/surface-frontend.md). The existing NULL
225
+ rows still need a data repair; the leak is closed, the backlog is not.
217
226
  - **⚠ `BundleItems.itemId` is a silent catalog-resolution consumer — the storefront never
218
227
  re-catalogs it.** The `toga2-commerce` storefront relays whatever item `GET /v2/bundles/{uuid}`
219
228
  returns; its `catalogId = 1` filter guards standalone item search only, never bundles. So a kit
@@ -241,6 +250,11 @@ children" rule:
241
250
  [catalog-fold reversal](../workflows/catalog-fold-reversal.md).
242
251
 
243
252
  ## Change history
253
+ - 2026-09-15 — Identified the **source of the newest NULL-`catalogId` items**: the `toga25-supply`
254
+ create form sent no catalog, so every item created in the 2.5 app was born catalog-less (**28** rows
255
+ in `Client_Compass`, newest 2026-09-14; **6** in `Client_CompassCanada`), and such an item never
256
+ reaches the storefront. The leak is closed — the create payload now takes its catalog from a seeded
257
+ Surface default — but the existing NULL rows are still unrepaired. (bala)
244
258
  - 2026-09-02 — **Narrowed the same-day uniqueness resolution below.** `Client_Compass.Items` holds
245
259
  `MD7F4LL/A-S` **twice** — id 2760 (`title` set, `isActive = 0`, the row all referencing bundles
246
260
  use) and id 2784 (`title` NULL, `isActive = 1`, unused) — a pair from outside the fold population, so
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: elite
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-08
9
+ updated: 2026-09-15
10
10
  owners: ["snaredla", "jcardinal", "rgirish"]
11
11
  files:
12
12
  - worker/crons/toga2/netsuite/sync_togasupply_elite.php
@@ -27,6 +27,7 @@ files:
27
27
  related:
28
28
  - ../profile.md
29
29
  - ../../../1.0/apps/library/features/netsuite-item-assettype-sync.md
30
+ - ../../../1.0/apps/library/features/netsuite-item-class-sync.md
30
31
  - ../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md
31
32
  - ../../../1.0/apps/worker/workflows/onboarding-client-to-netsuite-togasupply-sync.md
32
33
  - ./salesorder-netsuite-push.md
@@ -119,10 +120,11 @@ migration file.
119
120
 
120
121
  ### Elite-only wrapper deviations
121
122
 
122
- - **`MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS = 2592000` (30 days)**, vs. the 5-day default the
123
- other 17 wrappers inherit — Elite is backfilling from 2024 and 5-day windows on a 5-minute cron were
124
- too slow. This is only possible because the shared engine now `define()`s the cap instead of
125
- `const`-ing it. **Reduce it to the default once the backfill is caught up.**
123
+ - **~~`MAX_TIME_WINDOW_TO_FETCH_FROM_NETSUITE_SECONDS = 2592000` (30 days)~~ — REMOVED, and the
124
+ backfill it existed for is done (verified 2026-09-15).** Elite's wrapper no longer overrides the
125
+ cap, so it now inherits the shared **864000 (10 days)**. Keep this in mind before planning a
126
+ rewind: one Elite run advances 10 days, not 30. (The engine still `define()`s rather than
127
+ `const`s the cap, so an override remains possible if another deep backfill is ever needed.)
126
128
  - **⚠ EVERY integration flag is now `true` (2026-09-04) — including invoices, and that carries a
127
129
  live risk.** `_SALES_ORDERS`, `_INVOICES`, `_ITEM_RECEIPTS`, `_ITEM_FULFILLMENTS`,
128
130
  `_INVENTORY_ADJUSTMENTS` and `_TRANSFER_ORDERS` were all flipped `false → true`
@@ -266,6 +268,37 @@ failure/in-progress flag (you want `IDLE`), and the cap is **864000** (10 days),
266
268
  takes many runs. **Only reset after the underlying error is fixed**, or it just fails and shrinks
267
269
  again.
268
270
 
271
+ ### Elite is also the ONLY client opted in to itemClass + line fulfillmentType (2026-09-15)
272
+
273
+ Same shape as assetType above: two constants in Elite's wrapper, and nothing else turns them on.
274
+
275
+ ```php
276
+ const IS_ENABLED_NETSUITE_LINE_FULFILLMENT_TYPE = true; // :60
277
+ const IS_ENABLED_NETSUITE_ITEM_CLASSIFICATIONS = true; // :65
278
+ ```
279
+
280
+ Without them `toga2.php` omits the `fulfillmentType` and `itemClass` keys entirely, so **no other
281
+ client can hit `EV-9` from this work.** Confirmed by grep across all 19 `sync_togasupply_*.php`
282
+ launchers. Mechanics and the field-name trap live on the
283
+ [NetSuite item-class sync doc](../../../1.0/apps/library/features/netsuite-item-class-sync.md).
284
+
285
+ **Elite's ACL is fully provisioned — verified in production 2026-09-15.** All three layers are
286
+ granted, so no further ACL work is owed for Elite:
287
+
288
+ | Layer | Verified |
289
+ |---|---|
290
+ | `AclFieldPermissions` roleId 3 writable | RecordFields **601-605** + **2596** (605 = `Items.itemClassId`, 2596 = `ItemClasses.parentItemClassId`) and **2594/2595** (`SalesOrderItems`/`PurchaseOrderItems.fulfillmentType`) |
291
+ | `AclRecordPermissions` roleId 3 | record **105** (Item classes) create/read/update = 1; `allowUpdate = 1` on records **21** (Items) and **18** (Purchase order items) |
292
+ | `AclCustomFieldPermissions` | `c_netsuiteInternalClassId` (`CustomRecordFields` id 79, recordId 105) |
293
+ | paired `AclLogicGroups` | present for the record-105 permission |
294
+
295
+ Records **15, 18, 21, 105 are all `aclDatabase = CLIENT`**, so every one of these grants lives in
296
+ `Client_Elite`, not `Core` — the usual place this goes wrong.
297
+
298
+ **Elite has 3 duplicate `AclRecordPermissions` rows** for 105/roleId 3 (ids 346, 347, 348) and 3
299
+ logic groups on id 346, from the unguarded `2026-09-11c` migration. Harmless, but see the
300
+ [item-class doc](../../../1.0/apps/library/features/netsuite-item-class-sync.md) before cleaning.
301
+
269
302
  ## Gotchas / known issues
270
303
 
271
304
  - **⚠ OPEN, unrelated: `GET /v2/notes/{uuid}/files` returns 405 `EV-7`** ("method not allowed /
@@ -307,6 +340,17 @@ again.
307
340
 
308
341
  ## Change history
309
342
 
343
+ - 2026-09-15 — **itemClass + line `fulfillmentType` are now on for Elite, and Elite alone.** Recorded
344
+ the two wrapper constants (`IS_ENABLED_NETSUITE_ITEM_CLASSIFICATIONS`,
345
+ `IS_ENABLED_NETSUITE_LINE_FULFILLMENT_TYPE`) that gate the whole feature — no other launcher
346
+ defines either, so no other client is exposed. **Verified all three ACL layers in production**
347
+ (fields 601-605/2596 + 2594/2595, record 105 create/read/update plus `allowUpdate` on records 21
348
+ and 18, and the `c_netsuiteInternalClassId` custom-field grant), all in `Client_Elite` because
349
+ records 15/18/21/105 are `aclDatabase = CLIENT`. Also noted Elite's 3 duplicate
350
+ `AclRecordPermissions` rows from the unguarded `2026-09-11c` migration. The feature itself was a
351
+ no-op until a field-name fix landed the same day — see
352
+ [NetSuite item-class sync](../../../1.0/apps/library/features/netsuite-item-class-sync.md).
353
+ (rgirish)
310
354
  - 2026-09-08 — **Elite sales-order sync was fully blocked for 4 days by a transfer-order 400 EV-12**,
311
355
  and the reported symptom ("transfer orders missing") hid the real blast radius: nothing after
312
356
  `2026-09-04 14:02:52` synced. Root cause was **metadata, not code** —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.810",
3
+ "version": "1.0.812",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",