toga-ai 1.0.781 → 1.0.783

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.
@@ -16,6 +16,7 @@
16
16
  | [HTTP 500 Error Monitor (App_SystemMonitor_500Error) — and why its \"Error Type\" is not a diagnosis](features/http-500-error-monitor.md) | `App_SystemMonitor_500Error` (`library/app/systemmonitor/500error.php`, title **"HTTP 500 Error Alert"**) is the 1.0 system monitor that watches **`Logs.Api` fo | library/app/systemmonitor/500error.php, worker/crons/infrastructure/system_monitors.php, api2/Controller/Index.php, _underscore/Error.php |
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 | library/app/framework.php, library/app/frameworkindex.php, library/app/mvc.php, library/app/database.php, library/app/model.php, library/app/config.php |
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. | library/app/api/netsuite/rest.php, worker/crons/toga2/netsuite/netsuite_restlet_filecontent.js, worker/config.worker.ini |
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 | library/app/api/toga2.php, library/app/api/netsuite/rest.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/sync_togasupply_elite.php |
19
20
  | [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 | library/app/netsuite.php, library/app/api/toga2.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/backfill_isfulfillable_jul5.php |
20
21
  | [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 | library/app/api/netsuite/rest.php, library/ssl/netsuite_ec_key.pem, test/@dave/Junk Drawer/nsq.php |
21
22
  | [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. | library/app/api/netsuite/rest.php, library/app/netsuite.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
@@ -0,0 +1,202 @@
1
+ ---
2
+ title: assetType from NetSuite itemtype during Item 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-04
10
+ owners: [rgirish]
11
+ files:
12
+ - library/app/api/toga2.php
13
+ - library/app/api/netsuite/rest.php
14
+ - worker/crons/toga2/netsuite/common_sync_togasupply.php
15
+ - worker/crons/toga2/netsuite/sync_togasupply_elite.php
16
+ related:
17
+ - netsuite-item-isfulfillable-sync.md
18
+ - netsuite-suiteql-rest-shim.md
19
+ - toga2-api-client-and-bridge.md
20
+ - ../../worker/features/netsuite-togasupply-per-client-sync.md
21
+ - ../../../2.0/apps/api2/features/nested-relationship-writes.md
22
+ - ../../../2.0/apps/_underscore/features/acl-permission-chain.md
23
+ - ../../../clients/elite/features/netsuite-togasupply-sync.md
24
+ ---
25
+
26
+ ## Summary
27
+
28
+ `getCreateItem()` never sent `assetType`, so **every item the NetSuite importer created had
29
+ `Items.assetTypeId = NULL`** — for every client, since the importer began. The string `assetType`
30
+ appeared nowhere in `library/app/api/toga2.php` nor in `worker/crons/toga2/netsuite/*`.
31
+
32
+ Measured on production `Client_Elite.Items`: **113 rows, 110 with `assetTypeId = NULL`**. The only
33
+ 3 non-NULL rows (`Laptop`, `Test`, `Test 2`) have `c_netsuiteInternalItemId = NULL` and
34
+ `inventoryType = HYBRID` — hand-made test rows, not imported ones.
35
+
36
+ This doc covers the fix: NetSuite's `item.itemtype` is now mapped to an `AssetTypes.uuid` and
37
+ stamped on the item. It is **opt-in per client** and deliberately narrow, because two platform
38
+ facts (below) make the obvious implementation break item imports outright.
39
+
40
+ Sibling doc: [isFulfillable](netsuite-item-isfulfillable-sync.md) — the other NetSuite flag stamped
41
+ by the same function. **The two are not symmetric** and must not be implemented the same way; see
42
+ [Why this is not modeled on isFulfillable](#why-this-is-not-modeled-on-isfulfillable).
43
+
44
+ ## ⚠⚠ TWO blockers — read before adding this for another client
45
+
46
+ Both were found under CTO review of a first proposal that looked obviously correct. Either one
47
+ turns "the asset type is not set" into **"the item create fails outright."**
48
+
49
+ ### 1. `Items.assetTypeId` is not writable by the API role in most tenants
50
+
51
+ api2 **aborts the entire record write** when a payload names a field the authenticating role cannot
52
+ write. The NetSuite sync authenticates as **roleId 3 (API)**. So simply adding `assetType` to the
53
+ payload does not merely fail to set the asset type — it **fails the item create**.
54
+
55
+ Verified on production `Client_Elite.AclFieldPermissions`:
56
+
57
+ | recordFieldId | Field | Roles granted |
58
+ |---|---|---|
59
+ | **269** | `Items.assetTypeId` | roleId **1 (Base) only** |
60
+ | 107 | `Items.manufacturerId` | roleId **1 and 3 (API)** |
61
+
62
+ **A `dbchanges2/Client_<Tenant>/` grant must land BEFORE the code deploy.** Elite's is
63
+ `dbchanges2/Client_Elite/2026-09-04a - EliteItemAssetTypeApiWritePermission.sql`. Mechanics:
64
+ [ACL permission chain](../../../2.0/apps/_underscore/features/acl-permission-chain.md).
65
+
66
+ ### 2. `assetTypeId`'s childPolicy is `MATCH`, not `MATCH_UPSERT` — so get-or-create does NOT work
67
+
68
+ `getCreateManufacturer()` sits in the **same function** and does a get-or-create by name. **That
69
+ pattern does not transfer.** Manufacturers auto-create precisely because their field is
70
+ `MATCH_UPSERT`; `assetTypeId` is plain `MATCH`.
71
+
72
+ Verified on production `Core.RecordFields`: id **269** (`assetTypeId`) = `MATCH`; id **107**
73
+ (`manufacturerId`) = `MATCH_UPSERT`.
74
+
75
+ With `MATCH`, sending `assetType: {name: 'Services'}` for a name that has no row is a
76
+ **field-reference error that fails the record** — it does not create the row. Correct approach:
77
+ **resolve name → uuid up front (`GET /asset-types`) and send `{uuid}`.** Policy semantics:
78
+ [nested-relationship writes](../../../2.0/apps/api2/features/nested-relationship-writes.md).
79
+
80
+ > **General lesson:** never reason by analogy from a sibling nested write in the same function.
81
+ > Check that field's own `Core.RecordFields.childPolicy` first. Two fields on the same record
82
+ > routinely differ.
83
+
84
+ ## Key files / entry points
85
+
86
+ - `library/app/api/netsuite/rest.php` — `fetchItemFlagsByIds()` now selects `itemtype` in its
87
+ SuiteQL and exposes it as `->itemType`. **Free**: that query already selected from `item` once
88
+ per 500-id batch, so no extra round-trip. Returns `null` for an empty itemtype.
89
+ - `library/app/api/toga2.php`
90
+ - `getAssetTypeUuidForNetsuiteItemType()` (~6358) — new private helper. Resolves a NetSuite
91
+ itemtype to an `AssetTypes.uuid` through the launcher's opt-in map. Returns `null` when the
92
+ client has not opted in or the itemtype is unmapped; **throws** when a configured mapping names
93
+ an asset type the client does not have.
94
+ - `getCreateItem()` (~6474) — sends `assetType: {uuid}` on create, and **fills it on existing
95
+ items only when currently NULL**.
96
+ - `worker/crons/toga2/netsuite/common_sync_togasupply.php` (~298) — the bulk `/items` GET now
97
+ requests `assetType => ['uuid']`. **Without this the fill-NULL-only check has nothing to read and
98
+ would re-PUT every item every run** (the same trap `isFulfillable` fell into — see its doc).
99
+ - `worker/crons/toga2/netsuite/sync_togasupply_elite.php` — the only launcher that opts in today.
100
+
101
+ ## How it works
102
+
103
+ 1. `fetchItemFlagsByIds()` returns `->itemType` alongside the serialized / fulfillable flags, one
104
+ SuiteQL query per 500 items.
105
+ 2. `getCreateItem()` calls `getAssetTypeUuidForNetsuiteItemType($itemFlags->itemType, …)`.
106
+ 3. The helper reads the launcher constant `NETSUITE_ITEM_TYPE_TO_ASSET_TYPE_NAME`
107
+ (`itemtype => AssetTypes.name`). **If the constant is not defined, it returns `null`** and the
108
+ `assetType` key is **omitted from the payload entirely**.
109
+ 4. On first need it `GET /asset-types` once and builds a `strtoupper(name) => uuid` map, skipping
110
+ rows with a NULL/blank name. **The cache is keyed by client uuid** — one worker process can
111
+ handle several clients in a run.
112
+ 5. Name not found → **throw**, naming the itemtype, the mapped name, and the fix. Fail loud, per the
113
+ team rule for import/sync/cron code.
114
+ 6. Create → `assetType: {uuid}` in the POST. Existing item → set it **only if
115
+ `$existingItem->assetType` is NULL**.
116
+
117
+ ## Design rules (and why each one)
118
+
119
+ | Rule | Why |
120
+ |---|---|
121
+ | **Opt-in via a launcher constant** | `getCreateItem()` is shared by ~20 client sync launchers. Undefined constant ⇒ the payload key is absent ⇒ the other ~19 clients are **byte-for-byte unaffected**. |
122
+ | **Link by uuid, never by name** | `assetTypeId` is `MATCH` — a name for a missing row fails the whole record (blocker 2). |
123
+ | **Never auto-create an `AssetTypes` row** | `AssetTypes` is free text with per-client `AUTO_INCREMENT` ids and **no slug or code**, so nothing marks a row as sync-made vs. operator-made. Verified: `Client_Elite.AssetTypes` has 2 rows (1=`Laptop`, 2=`Services`); `Client_Compass.AssetTypes` has **38 hand-curated rows** (`ACCESSORY`, `EQUIPMENT`, `FEE`, `CONSULTING`, `WARRANTY`, `HP LAPTOP ACCESSORIES`, … plus an **empty-name row at id 23**) and **no `Services` row at all**. Auto-creating would pollute that taxonomy. |
124
+ | **Fail loudly on a missing asset type** | A silent skip leaves a client half-stamped with no signal. |
125
+ | **Fill-NULL-only on existing items** | `assetTypeId` is shared, **hand-curated per-client** data — unlike `isFulfillable`, which is a boolean NetSuite owns. Also means a manual TOGa correction of a NetSuite-mis-typed item **survives later syncs**. |
126
+ | **Cache keyed by client uuid** | One worker process can service several clients. |
127
+
128
+ ## Why this is not modeled on isFulfillable
129
+
130
+ [isFulfillable](netsuite-item-isfulfillable-sync.md) **refreshes on every difference** — and that
131
+ doc's own ⚠ CRITICAL section records the cost: the refresh **reverts any local override**, so a
132
+ hand fix silently disappears within a day.
133
+
134
+ `assetType` deliberately does the opposite (**fill-NULL-only**). NetSuite does not own this field;
135
+ the client's operators do. Do not "make it consistent" with `isFulfillable` — the asymmetry is the
136
+ design.
137
+
138
+ ## NetSuite `itemtype` does NOT cleanly identify services
139
+
140
+ `item.itemtype` **is** populated (account-wide: InvtPart 51336, NonInvtPart 2652, Group 585,
141
+ Service 472, Kit 50, OthCharge 24, Discount 17, Expense 6, Description 3). But **`NonInvtPart` is
142
+ mixed** — it holds real services. For Elite (113 items: 65 InvtPart, 11 Service, 17 NonInvtPart),
143
+ `itemtype = 'Service'` alone marks only **11 of ~28** real services; the rest sit in `NonInvtPart`
144
+ (`SVC-FS-DEPLOY`, `SVC-FS-SHIPPING-*`, `SVC-CI-RETAINER-RS`, `SVC-TS-Removal`, `Project - Cabling`).
145
+
146
+ So a usable mapping must include the non-inventory types, not just `Service`. Elite's map is in
147
+ [Elite's sync doc](../../../clients/elite/features/netsuite-togasupply-sync.md).
148
+
149
+ **Residual gap: NetSuite data hygiene, not a code problem.** Three Elite items are typed
150
+ `InvtPart` in NetSuite though they are services (`SVC-TS-ELITE-HDONBOARDING`, `CONFIG/INSTALL`,
151
+ `CONF-RM-INSTALL-SUPP`). **Decided: this is a client data-hygiene ask, NOT a code special case** —
152
+ a part-number allowlist inside a 20-client shared import is unmaintainable. Fill-NULL-only means a
153
+ manual TOGa correction sticks.
154
+
155
+ ## Gotchas / known issues
156
+
157
+ - **⚠ Existing items stay NULL until a cursor reset.** `getCreateItem()` only runs when a
158
+ transaction line references an item, so already-imported items are **not** back-stamped by
159
+ turning this on. Elite's 110 NULL items stay NULL until a cursor rollback makes the sync re-walk
160
+ transactions that reference them. Cursors live in the **client** database `Parameters` table
161
+ (key `NETSUITE_LAST_SYNC_CURSOR_*`, value `"<lastmodifieddate>|<netsuiteInternalId>"`), read via
162
+ `GET /parameters`. See [per-client sync](../../worker/features/netsuite-togasupply-per-client-sync.md).
163
+ - **⚠ Lumping `Discount` / `Expense` / `Description` into a "Services" asset type is semantically
164
+ loose.** Harmless while `assetTypeId` only hides rows on a page — a **latent bug if it ever drives
165
+ billing or reporting.** Revisit the map before wiring `assetTypeId` into either.
166
+ - **An empty-name `AssetTypes` row exists in the wild** (`Client_Compass` id 23). The name→uuid
167
+ cache skips NULL/blank names so it can never be matched by accident.
168
+ - **⚠ PRE-EXISTING BUG, deliberately NOT fixed — needs its own ticket.** The fulfillability refresh
169
+ (`toga2.php` ~6547) reads `$existingItem->isFulfillable`, but the bulk `GET /items` lookup in
170
+ `common_sync_togasupply.php` **never requested that field**. So `$currentFulfillable` is always
171
+ `null` and the sync almost certainly **PUTs `isFulfillable` on every item, every run, for every
172
+ client**. Left untouched on purpose: fixing it changes behavior for all ~20 clients and was out of
173
+ scope. This is the exact trap `assetType` avoids by requesting `assetType => ['uuid']` in the same
174
+ lookup.
175
+
176
+ ## Change history
177
+
178
+ - 2026-09-04 — **Built assetType stamping from NetSuite `itemtype`.** Root-caused that
179
+ `getCreateItem()` never sent `assetType`, so every imported item had `assetTypeId = NULL` (prod
180
+ `Client_Elite`: 110 of 113). Added `itemtype` to `fetchItemFlagsByIds()`'s existing SuiteQL
181
+ (free), the `getAssetTypeUuidForNetsuiteItemType()` helper, `assetType => ['uuid']` to the bulk
182
+ `/items` GET, and the create/fill paths. Recorded the **two blockers** that break the naive
183
+ version — `Items.assetTypeId` (recordField 269) is granted to **roleId 1 only** while the sync
184
+ authenticates as roleId 3, and api2 fails the **whole** record write on a non-writable field; and
185
+ 269's childPolicy is **`MATCH`**, not the `MATCH_UPSERT` that makes the sibling
186
+ `getCreateManufacturer()` get-or-create work, so a name-only nested object is a hard failure.
187
+ Design: opt-in per launcher, link by uuid, never auto-create an `AssetTypes` row, fail loud on a
188
+ missing one, **fill-NULL-only** (the deliberate opposite of `isFulfillable`'s revert-everything
189
+ refresh). Also collapsed the existing-item branch's **three separate PUTs to the same
190
+ `/items/<uuid>`** into one, and recorded the pre-existing `isFulfillable`-never-requested bug as a
191
+ separate ticket. (rgirish)
192
+
193
+ ## Related docs
194
+
195
+ - [isFulfillable from NetSuite during Item Sync](netsuite-item-isfulfillable-sync.md) — the sibling
196
+ flag on the same function, with the opposite refresh policy.
197
+ - [Nested-relationship writes](../../../2.0/apps/api2/features/nested-relationship-writes.md) —
198
+ `MATCH` vs `MATCH_UPSERT` and why link-by-uuid is the only safe form.
199
+ - [ACL permission chain](../../../2.0/apps/_underscore/features/acl-permission-chain.md) — the
200
+ field-write grant this feature needs per tenant.
201
+ - [Elite NetSuite → TOGa Supply sync](../../../clients/elite/features/netsuite-togasupply-sync.md)
202
+ — the only client opted in, and its itemtype map.
@@ -6,14 +6,15 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-12
10
- owners: [bala]
9
+ updated: 2026-09-04
10
+ owners: [bala, rgirish]
11
11
  files:
12
12
  - library/app/netsuite.php
13
13
  - library/app/api/toga2.php
14
14
  - worker/crons/toga2/netsuite/common_sync_togasupply.php
15
15
  - worker/crons/toga2/netsuite/backfill_isfulfillable_jul5.php
16
16
  related:
17
+ - netsuite-item-assettype-sync.md
17
18
  - toga2-api-client-and-bridge.md
18
19
  - ../../worker/features/netsuite-togasupply-per-client-sync.md
19
20
  - ../../worker/workflows/isfulfillable-multi-client-backfill.md
@@ -116,6 +117,22 @@ sweep that started 13:45 and set every item to `1`.
116
117
  each verified to trace through an Agilant source.
117
118
 
118
119
  ## Gotchas / known issues
120
+ - **⚠⚠ The refresh's diff is BROKEN — `isFulfillable` is never read back, so it is PUT on every
121
+ item, every run, for every client.** The refresh at `toga2.php` ~6547 compares NetSuite's value
122
+ against `$existingItem->isFulfillable`, but the bulk `GET /items` lookup in
123
+ `common_sync_togasupply.php` **never requested that field** — so `$currentFulfillable` is always
124
+ `null` and the diff can never match. The 2026-07-28 entry below claims the field was added to that
125
+ field list; **it is not there** (verified 2026-09-04). Net effect: the "diff-only PUT keeps
126
+ re-syncs no-op" guarantee does not hold. **Not fixed — needs its own ticket**, because a fix
127
+ changes write volume for all ~20 clients. The sibling
128
+ [assetType feature](netsuite-item-assettype-sync.md) avoids this by requesting
129
+ `assetType => ['uuid']` in the same lookup.
130
+ - **The existing-item branch now sends ONE PUT, not three.** It previously issued up to three
131
+ separate PUTs to the same `/items/<uuid>` with identical options (`inventoryType`,
132
+ `isFulfillable`, and now `assetType`). One payload is collected and a single PUT is sent when
133
+ non-empty. Side effect worth knowing: `inventoryType` was previously never written back to the
134
+ in-run lookup, so a repeat line for the same part number re-PUT it — all three fields now update
135
+ the lookup.
119
136
  - **Value stamped only on the Agilant source item here** — the client-facing copy is set by the 2.0
120
137
  interceptor. If the interceptor rows aren't deployed in the target env, api2 **403s the whole item
121
138
  write** on the unknown `isFulfillable` field (see the Phase-2 doc's deploy gotchas).
@@ -125,6 +142,15 @@ sweep that started 13:45 and set every item to `1`.
125
142
  in `Client_Compass.Apis` (name `Agilant`) — never reproduce the secret value.
126
143
 
127
144
  ## Change history
145
+ - 2026-09-04 — **Two corrections found while building the sibling
146
+ [assetType stamping](netsuite-item-assettype-sync.md) on the same function.** (1) The
147
+ existing-item **refresh diff never works**: the bulk `GET /items` field list in
148
+ `common_sync_togasupply.php` does **not** request `isFulfillable` (contrary to the 2026-07-28
149
+ entry below), so `$currentFulfillable` is always `null` and the flag is almost certainly PUT on
150
+ every item, every run, for every client. Recorded as a separate ticket — not fixed, because it
151
+ changes write volume for ~20 clients. (2) The existing-item branch's up-to-**three** PUTs to the
152
+ same `/items/<uuid>` were collapsed into a **single** PUT, and `inventoryType` is now written back
153
+ to the in-run lookup (it previously was not, so a repeat part number re-PUT it). (rgirish)
128
154
  - 2026-08-12 — Prod investigation (no code change): recorded that the **existing-item refresh
129
155
  reverts any local override** (NetSuite returns `T` for services; audit-log proof on Compass items
130
156
  2382/2384/2385, stamped NULL→1 on 2026-08-04), so an override must be enforced by a write-time
@@ -7,13 +7,14 @@ client: shared
7
7
  type: feature
8
8
  status: active
9
9
  updated: 2026-09-04
10
- owners: ["bala", "mhammontree", "tcox", "apeterson"]
10
+ owners: ["bala", "mhammontree", "tcox", "apeterson", "rgirish"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - _underscore/Model/Client/ContactEmailAddress.php
14
14
  - _underscore/Model/Client/TransferOrderStage.php
15
15
  related:
16
16
  - ../architecture.md
17
+ - ../../../1.0/apps/library/features/netsuite-item-assettype-sync.md
17
18
  - ../../toga2-supply/features/currency-amount-lines-editor.md
18
19
  - cxml-shipnotice-gateway.md
19
20
  - ../../../clients/aig/features/entitlement-intake.md
@@ -215,6 +216,46 @@ business keys to uuids in the caller. (Also note `Units.itemId` is NOT NULL, so
215
216
  omitted; and the `Units` identifier set is `uuid`/`itemId`/`serialNumber`/`assetTag`/`vendorId`/
216
217
  `macAddress`, so item + serialNumber is what gives find-or-create on a device.)
217
218
 
219
+ ### Plain `MATCH` (no UPSERT) makes a name-only nested object a HARD FAILURE — and a sibling field is no guide
220
+
221
+ `MATCH_UPSERT` creates the missing child. **Plain `MATCH` does not** — sending a nested object that
222
+ matches nothing is a **field-reference error that fails the whole record write**. It does not
223
+ "skip the field."
224
+
225
+ This bites hardest when the same function already does a working get-or-create on a *sibling* field
226
+ of the *same* record, because the pattern looks obviously reusable and is not. Measured on
227
+ production `Core.RecordFields` for `Items`:
228
+
229
+ | recordFieldId | Field | childPolicy | So a name-only nested object… |
230
+ |---|---|---|---|
231
+ | **107** | `Items.manufacturerId` | `MATCH_UPSERT` | **creates** the manufacturer — get-or-create by name works |
232
+ | **269** | `Items.assetTypeId` | `MATCH` | **fails the entire item write** |
233
+
234
+ The 1.0 NetSuite importer's `getCreateItem()` contains **both** calls. `getCreateManufacturer()`
235
+ sends `manufacturer: {name}` and has always worked; copying that shape for `assetType: {name}`
236
+ would have broken item creation outright.
237
+
238
+ > **Rule: before writing a nested object, check that field's OWN
239
+ > `Core.RecordFields.childPolicy`.** Never reason by analogy from a sibling field on the same
240
+ > record — two fields on one record routinely differ (the ASN tracking bridges above are the same
241
+ > lesson on three sibling bridges).
242
+ >
243
+ > **On a `MATCH` field, always resolve the business key to a `uuid` in the caller and send
244
+ > `{uuid}`.** For `AssetTypes` that means a `GET /asset-types` name→uuid map up front.
245
+
246
+ Worked case:
247
+ [assetType from NetSuite itemtype](../../../1.0/apps/library/features/netsuite-item-assettype-sync.md).
248
+
249
+ #### The other half of the same trap: a non-writable field fails the WHOLE record, not just the field
250
+
251
+ Same write, different gate. api2 **aborts the entire record write** when the payload names a field
252
+ the authenticating role cannot write — so an ACL gap on one nested FK reads as "creates stopped
253
+ working," not "that field is empty." Verified on production `Client_Elite.AclFieldPermissions`:
254
+ recordField **269** (`assetTypeId`) had **roleId 1 only**, while **107** (`manufacturerId`) had
255
+ **1 and 3**; the NetSuite sync authenticates as roleId 3. **The `dbchanges2/Client_<Tenant>/` grant
256
+ must land before the code that sends the field.** Chain details:
257
+ [ACL permission chain](../../_underscore/features/acl-permission-chain.md).
258
+
218
259
  ### Fix it with a migration — a manual one-row data patch on non-prod DOES NOT SURVIVE
219
260
 
220
261
  The TRUE-79978 beta fix was applied by hand as a single `UPDATE` on 2026-07-09. The **identical
@@ -357,6 +398,16 @@ transferOrderStage: { id: 7 }
357
398
  request time from a stable natural key (name/slug), because ids differ per tenant DB. Distinguished
358
399
  this from the existing `searchableIdentifierFields: ["id"]` flavor, which is a missing
359
400
  `isIdentifier` on a `c_` field. Found building the transfer-order create POST. (apeterson)
401
+ - 2026-09-04 — Recorded that **plain `MATCH` (without UPSERT) makes a name-only nested object a hard
402
+ failure of the whole record write**, not a skipped field — and that **a sibling field on the same
403
+ record is no guide**. Prod `Core.RecordFields` on `Items`: **107** (`manufacturerId`) is
404
+ `MATCH_UPSERT` so `getCreateManufacturer()`'s get-or-create-by-name works, while **269**
405
+ (`assetTypeId`) is `MATCH`, so copying that shape into the same function would have broken item
406
+ creation. Rule: check the field's own `childPolicy`, and on a `MATCH` field resolve the business
407
+ key to a `uuid` in the caller. Also recorded the paired ACL gate — api2 aborts the **entire**
408
+ record write on a non-writable field (269 was granted roleId 1 only; the NetSuite sync
409
+ authenticates as roleId 3), so the tenant grant migration must precede the code deploy. Surfaced
410
+ under CTO review of the 1.0 NetSuite assetType import. (rgirish)
360
411
 
361
412
  - 2026-08-26 — Added: **the UPDATE path sets status 200 unconditionally after its children loop**, so
362
413
  a nested `PUT` reports success even when a child write failed (unique-key rejection, ACL denial).
@@ -4,7 +4,7 @@
4
4
  |-----|---------|-------|
5
5
  | [Database Changes (dbchanges2) Repository Architecture](architecture.md) | `dbchanges2` is the **schema-migration / SQL change-set repository** for the entire 2.0 platform. | Core/, Client/, Client_<Tenant>/, Logs/, Logs_Client/, _modules/ |
6
6
  | [Re-runnable additive migrations (uuid4 in SQL, INSERT guards, the DISTINCT trap, conditional ALTERs)](features/rerunnable-additive-inserts.md) | Most `dbchanges2` files are **additive data grants** run by hand against production, often more than once (once per environment, or twice because someone was no | dbchanges2/Client_Compass/2026-08-26a - CompassCreativeStudioPersona.sql, dbchanges2/Core/2026-08-27a - Insert - Netsuite Location SyncAll CronJob.sql, dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql |
7
- | [Surface Layer Schema (UI presentation/config tables)](features/surface-layer-schema.md) | The persistent schema for the platform-wide **Surface** UI presentation/configuration layer (see the `_underscore` [surface-resolver](../../_underscore/features | dbchanges2/Client_Compass/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql, dbchanges2/Client_CompassCanada/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql, dbchanges2/Core/2026-09-02a - TalosAssistantSurfaceSeed.sql, dbchanges2/Client_Nychh/2026-09-02a - TalosAssistantEnable.sql, dbchanges2/Core/2026-08-31a - SalesOrderDenialDetailsSurfaceSeed.sql, dbchanges2/Client_Compass/2026-08-31a - SalesOrderDenialDetailsReadOnlyRules.sql, toga25-supply/db-migrations/PLAYBOOK.md, toga25-supply/db-migrations/SURFACE-FEATURE-RUNBOOK.md, dbchanges2/Client_Compass/, dbchanges2/Client_CompassCanada/, dbchanges2/Client_Quad/, dbchanges2/Core/2026-08-28b - TransferOrderListActionsSurfaceSeed.sql, dbchanges2/Core/2026-08-24b - SalesOrderStatusFilterSurfaceSeed.sql, dbchanges2/Client_Compass/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_CompassCanada/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_Quad/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_Nychh/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Core/2026-08-21 - SalesOrderDecisionSurfacesReseed.sql, dbchanges2/Core/2026-08-24 - RestoreApproveDenyRowActionsVisibility.sql, dbchanges2/Client_Quad/2026-08-24 - ProdPortApprovePoNumberEnabledRule.sql, dbchanges2/Client_Quad/2026-07-21b - ApproveDisabledTooltipPoNumber.sql, dbchanges2/Client_Quad/2026-08-21 - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-08-24 - ProdPortApprovalsGateAndApproveStepTwoRule.sql, dbchanges2/Client_CompassCanada/2026-08-24 - ProdPortApprovalsGateAndApproveStepTwoRule.sql, dbchanges2/Client_Nychh/2026-08-24 - FixInventoryGroupingsUnitsTopologyOverrideIds.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client_Compass/2026-06-25d - SalesOrderSurfaceClientSeed.sql, _underscore/Model/Client/ThemeToken.php, toga25-supply/src/themeConfig.json, dbchanges2/Core/2026-06-25a - SurfaceCoreTables.sql, dbchanges2/Core/2026-06-25b - SurfaceRecordsAndFields.sql, dbchanges2/Core/2026-06-25c - SalesOrderLoginSurfaceSeed.sql, dbchanges2/Core/2026-06-29a - ItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Core/2026-06-29d - VendorItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29e - InventorySurfaceSeed.sql, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Compass/2026-06-30a - SalesOrderDisplaySectionManagerOverrides.sql, dbchanges2/Client_CompassCanada/2026-06-30a - SalesOrderSurfaceManagerOverrides.sql, dbchanges2/Client_Quad/2026-06-30a - SalesOrderSurfaceClientOverrides.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client/2026-06-25b - SurfaceClientSeed.sql, dbchanges2/Client/2026-06-25c - SurfaceClientAcl.sql, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Core/2026-07-17h - Update - ClearApprovalsFilterButtonConfig.sql, dbchanges2/Client_Compass/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Compass/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_Quad/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Quad/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Core/2026-07-20a - Update - HideAdminNotesSectionByDefault.sql, dbchanges2/Core/2026-07-20b - Update - NotesSectionFieldElements.sql, dbchanges2/Client_Compass/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_Compass/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_Quad/2026-07-20a - NotesSectionFieldsOverride.sql, dbchanges2/Core/2026-07-20c - Update - VendorItemsToggleSurfaceSeed.sql, dbchanges2/Client_Compass/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_Compass/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Core/2026-07-20e - RestoreApproveDenyRowActions.sql, dbchanges2/Client_Compass/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Core/2026-07-17 - README - RUN ORDER.md, dbchanges2/Client_Compass/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Compass/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_CompassCanada/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_CompassCanada/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_Quad/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Quad/2026-07-21b - SalesOrderApproveDisabledTooltipTranslation.sql, dbchanges2/Core/2026-07-21a - SalesOrderDecisionSummarySurfaceSeed.sql, dbchanges2/Core/2026-07-21b - SalesOrderDecisionActionSurfaceSeed.sql, dbchanges2/Client_Quad/2026-07-21c - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Core/2026-07-23a - PoNumberDetailFieldValueKey.sql, dbchanges2/Core/2026-07-29a - SalesOrderApprovalDetailsSurfaceSeed.sql, dbchanges2/Core/2026-08-05b - ApprovalDetailsShippingMethodConcatCharge.sql, dbchanges2/Core/2026-08-14a - NoteBadgesVocabularySurface.sql, dbchanges2/Client/2026-08-14a - NoteBadgeThemeTokens.sql, dbchanges2/Client/2026-08-14b - NoteBadgeUserColorFix.sql, dbchanges2/Client_Compass/2026-08-14a - NoteBadgeDelegateThemeTokens.sql, dbchanges2/Client_CompassCanada/2026-08-14a - NoteBadgeDelegateThemeTokens.sql, dbchanges2/Core/2026-08-26a - ServiceRequestRecordHeaderSurfaceSeed.sql, dbchanges2/Core/2026-08-26b - ServiceRequestDetailsSurfaceSeed.sql, dbchanges2/Core/2026-08-28a - TransferOrderSurfaceSeed.sql, dbchanges2/Client/2026-08-28a - TransferOrderStageThemeTokens.sql, dbchanges2/Client_Nychh/2026-08-28b - TransferOrdersNavigationEnable.sql, dbchanges2/Core/2026-09-03c - ColumnsModalSurfaceSeed.sql, dbchanges2/Core/2026-09-03d - ColumnsModalRetireConfigModal.sql |
7
+ | [Surface Layer Schema (UI presentation/config tables)](features/surface-layer-schema.md) | The persistent schema for the platform-wide **Surface** UI presentation/configuration layer (see the `_underscore` [surface-resolver](../../_underscore/features | dbchanges2/Client_Compass/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql, dbchanges2/Client_CompassCanada/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql, dbchanges2/Core/2026-09-03a - TransferOrderStatusBadgeRecordFields.sql, dbchanges2/Core/2026-09-03b - InventoryCreateTransferOrderAction.sql, dbchanges2/Client_Nychh/2026-09-03a - TransferOrdersStatusBadge.sql, dbchanges2/Client_Nychh/2026-09-03b - InventoryCreateTransferOrderShow.sql, dbchanges2/Core/2026-09-02a - TalosAssistantSurfaceSeed.sql, dbchanges2/Client_Nychh/2026-09-02a - TalosAssistantEnable.sql, dbchanges2/Core/2026-08-31a - SalesOrderDenialDetailsSurfaceSeed.sql, dbchanges2/Client_Compass/2026-08-31a - SalesOrderDenialDetailsReadOnlyRules.sql, toga25-supply/db-migrations/PLAYBOOK.md, toga25-supply/db-migrations/SURFACE-FEATURE-RUNBOOK.md, dbchanges2/Client_Compass/, dbchanges2/Client_CompassCanada/, dbchanges2/Client_Quad/, dbchanges2/Core/2026-08-28b - TransferOrderListActionsSurfaceSeed.sql, dbchanges2/Core/2026-08-24b - SalesOrderStatusFilterSurfaceSeed.sql, dbchanges2/Client_Compass/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_CompassCanada/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_Quad/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_Nychh/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Core/2026-08-21 - SalesOrderDecisionSurfacesReseed.sql, dbchanges2/Core/2026-08-24 - RestoreApproveDenyRowActionsVisibility.sql, dbchanges2/Client_Quad/2026-08-24 - ProdPortApprovePoNumberEnabledRule.sql, dbchanges2/Client_Quad/2026-07-21b - ApproveDisabledTooltipPoNumber.sql, dbchanges2/Client_Quad/2026-08-21 - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-08-24 - ProdPortApprovalsGateAndApproveStepTwoRule.sql, dbchanges2/Client_CompassCanada/2026-08-24 - ProdPortApprovalsGateAndApproveStepTwoRule.sql, dbchanges2/Client_Nychh/2026-08-24 - FixInventoryGroupingsUnitsTopologyOverrideIds.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client_Compass/2026-06-25d - SalesOrderSurfaceClientSeed.sql, _underscore/Model/Client/ThemeToken.php, toga25-supply/src/themeConfig.json, dbchanges2/Core/2026-06-25a - SurfaceCoreTables.sql, dbchanges2/Core/2026-06-25b - SurfaceRecordsAndFields.sql, dbchanges2/Core/2026-06-25c - SalesOrderLoginSurfaceSeed.sql, dbchanges2/Core/2026-06-29a - ItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Core/2026-06-29d - VendorItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29e - InventorySurfaceSeed.sql, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Compass/2026-06-30a - SalesOrderDisplaySectionManagerOverrides.sql, dbchanges2/Client_CompassCanada/2026-06-30a - SalesOrderSurfaceManagerOverrides.sql, dbchanges2/Client_Quad/2026-06-30a - SalesOrderSurfaceClientOverrides.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client/2026-06-25b - SurfaceClientSeed.sql, dbchanges2/Client/2026-06-25c - SurfaceClientAcl.sql, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Core/2026-07-17h - Update - ClearApprovalsFilterButtonConfig.sql, dbchanges2/Client_Compass/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Compass/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_Quad/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Quad/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Core/2026-07-20a - Update - HideAdminNotesSectionByDefault.sql, dbchanges2/Core/2026-07-20b - Update - NotesSectionFieldElements.sql, dbchanges2/Client_Compass/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_Compass/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_Quad/2026-07-20a - NotesSectionFieldsOverride.sql, dbchanges2/Core/2026-07-20c - Update - VendorItemsToggleSurfaceSeed.sql, dbchanges2/Client_Compass/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_Compass/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Core/2026-07-20e - RestoreApproveDenyRowActions.sql, dbchanges2/Client_Compass/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Core/2026-07-17 - README - RUN ORDER.md, dbchanges2/Client_Compass/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Compass/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_CompassCanada/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_CompassCanada/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_Quad/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Quad/2026-07-21b - SalesOrderApproveDisabledTooltipTranslation.sql, dbchanges2/Core/2026-07-21a - SalesOrderDecisionSummarySurfaceSeed.sql, dbchanges2/Core/2026-07-21b - SalesOrderDecisionActionSurfaceSeed.sql, dbchanges2/Client_Quad/2026-07-21c - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Core/2026-07-23a - PoNumberDetailFieldValueKey.sql, dbchanges2/Core/2026-07-29a - SalesOrderApprovalDetailsSurfaceSeed.sql, dbchanges2/Core/2026-08-05b - ApprovalDetailsShippingMethodConcatCharge.sql, dbchanges2/Core/2026-08-14a - NoteBadgesVocabularySurface.sql, dbchanges2/Client/2026-08-14a - NoteBadgeThemeTokens.sql, dbchanges2/Client/2026-08-14b - NoteBadgeUserColorFix.sql, dbchanges2/Client_Compass/2026-08-14a - NoteBadgeDelegateThemeTokens.sql, dbchanges2/Client_CompassCanada/2026-08-14a - NoteBadgeDelegateThemeTokens.sql, dbchanges2/Core/2026-08-26a - ServiceRequestRecordHeaderSurfaceSeed.sql, dbchanges2/Core/2026-08-26b - ServiceRequestDetailsSurfaceSeed.sql, dbchanges2/Core/2026-08-28a - TransferOrderSurfaceSeed.sql, dbchanges2/Client/2026-08-28a - TransferOrderStageThemeTokens.sql, dbchanges2/Client_Nychh/2026-08-28b - TransferOrdersNavigationEnable.sql, dbchanges2/Core/2026-09-03c - ColumnsModalSurfaceSeed.sql, dbchanges2/Core/2026-09-03d - ColumnsModalRetireConfigModal.sql |
8
8
  | [2.0 New-Client Onboarding (manual process)](workflows/client-onboarding.md) | > **A local browser wizard now automates this.** Steps 2–9 below (create DBs, generate Core/API > inserts, append to `Clients_Db.txt`) — plus the dbchanges2 bla | Client/, Client_<Tenant>/, Core/, Logs_Client/ |
9
9
  | [Auditing a client DB that drifted from its models (partially applied module migration)](workflows/client-schema-drift-audit.md) | A recurring 2.0 failure mode: **one client's database drifts from what the PHP models declare**, usually because a `_modules/<module>/` migration was applied to | dbchanges2/Client_Growrk/2026-05-28.sql, dbchanges2/Client_Growrk/2026-08-10c - GrowrkServiceRequestCustomFieldsCatchUp.sql, dbchanges2/Client_Growrk/2026-08-10d - GrowrkServiceRequestTypeAndDispositionSeeds.sql, dbchanges2/_modules/netsuite/2026-07-10a - UnitInventoryFields.sql, dbchanges2/Client_Growrk/2026-08-10 - GrowrkUnitInventoryFieldsCatchUp.sql, dbchanges2/Client_Growrk/2026-08-10b - GrowrkUnitItemDescriptionAcl.sql, dbchanges2/Client_Growrk/_modules.txt |
10
10
  | [Framework branch running AHEAD of schema — environment-wide 1054/EO-1 after a deploy](workflows/framework-branch-ahead-of-schema.md) | A **third** kind of 2.0 schema drift, distinct from the two already documented: nobody's database went backwards — **the code went forwards**. | _underscore/Model/Core/RecordField.php, _underscore/Model/Client/TableViewField.php, _underscore/Model/Client/TableView.php, _underscore/Model/Core/Surface.php, _underscore/Model/Client/Urgency.php, _underscore/Model/Client/RecordFieldSetting.php, _underscore/Model/Client/ClientRecordFieldSetting.php, api2/Component/Api/V2/V2.php, dbchanges2/Core/2026-08-14c - RecordFieldsValidationMetadata.sql, dbchanges2/Client/2026-08-14a - RecordFieldSettingsClientTable.sql, dbchanges2/Client/2026-08-13a - UserNavPreferencesClientTable.sql, dbchanges2/Client/2026-08-13c - PersonaNavVisibilityClientTable.sql |
@@ -11,6 +11,10 @@ owners: [jcardinal, apeterson, tcox]
11
11
  files:
12
12
  - dbchanges2/Client_Compass/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql
13
13
  - dbchanges2/Client_CompassCanada/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql
14
+ - dbchanges2/Core/2026-09-03a - TransferOrderStatusBadgeRecordFields.sql
15
+ - dbchanges2/Core/2026-09-03b - InventoryCreateTransferOrderAction.sql
16
+ - dbchanges2/Client_Nychh/2026-09-03a - TransferOrdersStatusBadge.sql
17
+ - dbchanges2/Client_Nychh/2026-09-03b - InventoryCreateTransferOrderShow.sql
14
18
  - dbchanges2/Core/2026-09-02a - TalosAssistantSurfaceSeed.sql
15
19
  - dbchanges2/Client_Nychh/2026-09-02a - TalosAssistantEnable.sql
16
20
  - dbchanges2/Core/2026-08-31a - SalesOrderDenialDetailsSurfaceSeed.sql
@@ -145,6 +149,25 @@ per-tenant deltas live in **Client** (mirrors `TableViews`/ACL/`ItemTranslations
145
149
  `value varchar(255)` + `c_longValue mediumtext`. Precedence **base < client < persona < role**
146
150
  with language as an orthogonal overlay; the resolver applies it most-specific-last in one pass.
147
151
 
152
+ ### Which family is this table in? — the cheat sheet a UI-layer migration actually needs
153
+
154
+ Every `.sql` file for a UI-layer change has to be checked statement-by-statement against the
155
+ **cluster-isolation hard rule** (see the [dbchanges2 architecture](../architecture.md)), and the
156
+ tables that come up over and over split cleanly. **Verified placement:**
157
+
158
+ | Family | Tables |
159
+ |---|---|
160
+ | **CORE** | `Records`, `RecordFields`, `Messages`, `Surfaces`, `SurfaceElements`, `Actions`, `Vocabularies`, `VocabularyTerms` |
161
+ | **CLIENT** (per tenant) | `TableViews`, `TableViewJoins`, `TableViewFields`, `AclRecordPermissions` / `AclFieldPermissions` / the rest of `Acl*`, `SurfaceOverrides`, `MessageTranslations`, `ThemeTokens` |
162
+
163
+ Proof for the TableView half, rather than assumption: `_underscore/Model/Client/TableView.php:74`
164
+ declares `database: _underscore::DB_CLIENT`. The Core half is the `Model/Core/*` counterpart.
165
+
166
+ **The practical consequence:** a badge, a button or a column almost always needs **two files** — a
167
+ `Core/` one and a `Client_<Tenant>/` one — and **every Core id the client file needs must be inlined
168
+ as a literal, never sub-selected**. A `SELECT … FROM Core.…` inside a `Client_*` file cannot resolve
169
+ in production and the hook refuses the write.
170
+
148
171
  ## SurfaceElements — two orthogonal axes + edit-mode columns (one tree serves view + edit)
149
172
 
150
173
  A record modal is modeled as **ONE** set of `<record>-record-*` SECTION surfaces (a header +
@@ -872,6 +895,14 @@ rule resumes.
872
895
 
873
896
  ## Gotchas
874
897
 
898
+ - **🚨 A reserved-id comment block is NOT evidence.** Repo comments claimed `SurfaceElements`
899
+ reserved through 218; prod's `MAX(id)` was 220 and sandbox-client's was 232 (2026-09-03). Seeds
900
+ using AUTO_INCREMENT + `NOT EXISTS` consume ids invisibly and land differently per environment.
901
+ **Measure `MAX(id)` in every environment before picking a literal.**
902
+ - **`TableViews` / `TableViewJoins` / `TableViewFields` / `Acl*` are CLIENT tables**
903
+ (`_underscore/Model/Client/TableView.php:74` → `DB_CLIENT`); `Records` / `RecordFields` /
904
+ `Messages` / `Surfaces` / `SurfaceElements` are **CORE**. A UI-layer change therefore needs two
905
+ files, and the client file must inline every Core id as a literal.
875
906
  - **🚨 SHIP-TOGETHER: `Client_Nychh/2026-08-24 - FixInventoryGroupingsUnitsTopologyOverrideIds.sql`
876
907
  MUST be in the SAME release as `Core/2026-08-21 - SalesOrderDecisionSurfacesReseed.sql`.**
877
908
  `Client_Nychh/2026-08-13a` resolved its Core ids via a cross-DB join and **stored the authoring
@@ -1058,6 +1089,22 @@ exactly (`MAX(Surfaces.id)` 44, `MAX(SurfaceElements.id)` 160) — that was **ve
1058
1089
  Using literals is still correct: a collision then fails loudly on the primary key instead of silently
1059
1090
  mis-wiring (which is what `INSERT IGNORE` did in the 2026-08-24 incident above).
1060
1091
 
1092
+ #### 🚨 The reserved-id COMMENTS in this repo do not match reality — and they drift PER ENVIRONMENT
1093
+
1094
+ The comment blocks at the top of the seed files are a record of what was **claimed**, not of what is
1095
+ **taken**. Verified 2026-09-03: the repo comments said `SurfaceElements` were reserved through
1096
+ **218**, while `SELECT MAX(id)` returned **220 on production** and **232 on sandbox-client**.
1097
+
1098
+ **Cause:** several seeds insert `SurfaceElements` with **AUTO_INCREMENT behind a `NOT EXISTS`
1099
+ guard** (the purely-additive pattern documented below) rather than with a literal id. Those rows are
1100
+ real and consume ids, but they leave no literal anywhere for a later author to read — and because
1101
+ each environment runs them at a different time against a different ceiling, the ids they land on
1102
+ **differ per environment**.
1103
+
1104
+ > **Rule: measure `MAX(id)` in EVERY environment before picking a literal id.** Never take the
1105
+ > number from a comment block, a change-history entry, or this doc. The two authoring patterns are
1106
+ > both legitimate, but mixing them means the repo can never be an id ledger.
1107
+
1061
1108
  #### Ceiling snapshot, verified read-only 2026-08-28 (before reserving the transfer-order block)
1062
1109
 
1063
1110
  | Environment | `MAX(Surfaces.id)` | `MAX(SurfaceElements.id)` | `MAX(Messages.id)` |
@@ -1383,6 +1430,21 @@ still unrun at the time, so this is a reservation, not a deployed state.
1383
1430
  rendered empty. Also recorded the **FE-first ship order**: the override alone exposes an
1384
1431
  unformatted value and crashes the modal (React error #31). Run on **client-sandbox `Client_Compass`
1385
1432
  only**; prod and Canada pending. (tcox)
1433
+ - 2026-09-04 — 🚨 **The reserved-id comment blocks in this repo do NOT match reality, and drift per
1434
+ environment.** Comments said `SurfaceElements` were reserved through **218**; `SELECT MAX(id)`
1435
+ returned **220 on production** and **232 on sandbox-client**. Cause: several seeds insert
1436
+ `SurfaceElements` with **AUTO_INCREMENT behind a `NOT EXISTS` guard** instead of a literal id, so
1437
+ the rows consume ids invisibly and land on different numbers in each environment. Rule recorded:
1438
+ **measure `MAX(id)` in every environment before picking a literal** — a comment records a claim,
1439
+ not a reservation. Also added a **verified Core-vs-Client placement cheat sheet** for the tables a
1440
+ UI-layer migration touches (`TableViews`/`TableViewJoins`/`TableViewFields`/`Acl*`/
1441
+ `SurfaceOverrides`/`MessageTranslations`/`ThemeTokens` = **CLIENT**, proven at
1442
+ `_underscore/Model/Client/TableView.php:74` → `DB_CLIENT`; `Records`/`RecordFields`/`Messages`/
1443
+ `Surfaces`/`SurfaceElements`/`Actions`/`Vocabularies`/`VocabularyTerms` = **CORE**), because a
1444
+ badge or button change is almost always a two-file change and every Core id in the client file
1445
+ must be an inlined literal. Confirmed on the transfer-order status badge + Inventory
1446
+ create-transfer-order button seeds (`Core/2026-09-03a`+`b`, `Client_Nychh/2026-09-03a`+`b`), which
1447
+ again followed the Core-seeds-OFF / tenant-opts-in default. (apeterson)
1386
1448
  - 2026-09-03 — Recorded the rule that **a modal needing surface-driven copy becomes its own Surface**:
1387
1449
  the resolver reads messages only from `*MessageId` columns and never from `config`, so
1388
1450
  `Core/2026-08-28b`'s `config.modal` keys could never resolve and the Columns modal rendered raw
@@ -10,7 +10,7 @@
10
10
  | [Force Logout on Deployment (useDeploymentGuard)](features/force-logout-on-deployment.md) | On large deployments the backend bumps the Core parameter `META_LAST_REFRESH_DATETIME`. | toga25-supply/src/hooks/useDeploymentGuard.tsx, toga25-supply/src/App.tsx |
11
11
  | [Meta-Driven Page & Table Setup](features/meta-driven-table-data.md) | A page in this app is **meta-driven end to end**: the page view model fetches *page meta* (labels, sections, ACL) and *table meta* (the columns/fields + table s | toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/TransferOrders/viewModel/useTransferOrdersPageViewModel.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableState.ts, toga25-supply/src/hooks/useTablePageMeta.ts, toga-blox-npm/dist/hooks/useFetchPageMeta.d.ts, toga-blox-npm/dist/hooks/useFetchTablePageMeta.d.ts, toga-blox-npm/dist/hooks/useAssignTableFieldLabels.d.ts, toga-blox-npm/dist/components/Table/hooks/useTableData.d.ts |
12
12
  | [Persisted React Query cache (localStorage `supply-chain-query-cache`)](features/persisted-query-cache.md) | `localStorage["supply-chain-query-cache"]` is **not a hand-written cache**. | toga25-supply/src/App.tsx, toga25-supply/src/contexts/AuthContext.tsx, toga25-supply/src/hooks/useDeploymentGuard.tsx, toga25-supply/src/fieldsConfig/useClientFields.ts, toga25-supply/src/surface/useFetchSurfaceMeta.ts, toga25-supply/src/hooks/useCurrentUser.ts, toga25-supply/src/hooks/useStatusTypeValues.ts, toga25-supply/src/surface/useStatusColors.ts, toga25-supply/vite.config.ts |
13
- | [Record Modals & Nested Tables](features/record-modals-and-nested-tables.md) | The repo's family of modal + nested-table patterns layered over toga-blox `TableRecordModal` and `PrimaryTable*Layout`. | toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/hooks/usePurchaseOrderDetails.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/FIELDS/apiFields.json, toga25-supply/src/pages/SalesOrders/helpers/surfaceBundleToTenantFields.ts, toga25-supply/src/layout/PrimaryTableServerLayout/PrimaryTableServerLayout.tsx, toga25-supply/src/layout/PrimaryTableServerLayout/types.ts, toga25-supply/src/layout/ItemRecordModalLayout/, toga25-supply/src/layout/SalesOrderRecordModalLayout/, toga25-supply/src/layout/SalesOrderItemsTableLayout/, toga25-supply/src/layout/ItemFulfillmentModal/, toga25-supply/src/layout/GenericNestedTables/GenericNestedTables.tsx, toga25-supply/src/layout/GenericNestedTables/GenericTableLayout.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/pages/Inventory/viewModel/FIELDS/DEFAULT/inventoryGroupings.json, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/TransferOrderRecordModalLayout.tsx, toga25-supply/src/pages/TransferOrders/helpers/buildInventoryPurchaseOrderUrl.ts, toga25-supply/src/hooks/useTableCellInteractions.ts, toga25-supply/src/hooks/useServerTableUrlState.ts, toga25-supply/src/layout/RecordApprovalModal/helpers/handleFormatApprovalWorkflowPayload.ts, toga25-supply/src/layout/RecordApprovalModal/api/approvalDecisionsApi.ts, toga25-supply/src/layout/RecordApprovalModal/ApprovalModal.module.css |
13
+ | [Record Modals & Nested Tables](features/record-modals-and-nested-tables.md) | The repo's family of modal + nested-table patterns layered over toga-blox `TableRecordModal` and `PrimaryTable*Layout`. | toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/hooks/usePurchaseOrderDetails.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/FIELDS/apiFields.json, toga25-supply/src/pages/SalesOrders/helpers/surfaceBundleToTenantFields.ts, toga25-supply/src/layout/PrimaryTableServerLayout/PrimaryTableServerLayout.tsx, toga25-supply/src/layout/PrimaryTableServerLayout/types.ts, toga25-supply/src/layout/ItemRecordModalLayout/, toga25-supply/src/layout/SalesOrderRecordModalLayout/, toga25-supply/src/layout/SalesOrderItemsTableLayout/, toga25-supply/src/layout/ItemFulfillmentModal/, toga25-supply/src/layout/ItemFulfillmentModal/useItemFulfillmentModalViewModel.tsx, toga25-supply/src/layout/GenericNestedTables/GenericNestedTables.tsx, toga25-supply/src/layout/GenericNestedTables/GenericTableLayout.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/pages/Inventory/viewModel/FIELDS/DEFAULT/inventoryGroupings.json, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/TransferOrderRecordModalLayout.tsx, toga25-supply/src/pages/TransferOrders/helpers/buildInventoryPurchaseOrderUrl.ts, toga25-supply/src/hooks/useTableCellInteractions.ts, toga25-supply/src/hooks/useServerTableUrlState.ts, toga25-supply/src/layout/RecordApprovalModal/helpers/handleFormatApprovalWorkflowPayload.ts, toga25-supply/src/layout/RecordApprovalModal/api/approvalDecisionsApi.ts, toga25-supply/src/layout/RecordApprovalModal/ApprovalModal.module.css |
14
14
  | [Side navigation & default route — an empty nav renders a BLANK PAGE and gets reported as "cannot log in"](features/side-navigation-and-default-route.md) | The 2.5 side nav is **100% backend-driven** by the `navigation` surface bundle, and the same list also decides **which routes exist** and **where `/` lands**. | toga25-supply/src/routes.tsx, toga25-supply/src/layout/AppLayout/viewModel/useAppLayoutViewModel.ts, api2/Component/Api/V2/V2.php, _underscore/Model/Core/Surface.php |
15
15
  | [SSO redirect & public-vs-user session gating (useAuthenticationFlow)](features/sso-redirect-and-session-gating.md) | How 2.5 Supply decides, on every navigation, whether an anonymous visitor should be bounced to their client's SSO IdP instead of the local `/login` form. | toga25-supply/src/hooks/useAuthenticationFlow.ts, toga25-supply/src/routes.tsx, toga25-supply/src/contexts/AuthContext.tsx, toga25-supply/src/api/api.ts |
16
16
  | [Surface Frontend (DB-driven UI consumption, src/surface/)](features/surface-frontend.md) | The frontend consumer of the platform-wide Surface layer — DB-driven UI config fetched from `GET /v2/surfaces/meta?slug=<slug>` instead of statically-imported J | toga25-supply/src/layout/RecordApprovalModal/helpers/stackedCurrencyJoiner.ts, toga25-supply/src/layout/RecordApprovalModal/helpers/stackedCurrencyJoiner.test.ts, toga25-supply/src/App.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/DenialBanner.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/SalesOrderRecordModal.module.css, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/SalesOrderApprovalModalsLayout.tsx, toga25-supply/src/layout/RecordApprovalModal/RecordApprovalModalLayout.tsx, toga25-supply/src/layout/RecordApprovalModal/view/ApprovalTimelineView.tsx, toga25-supply/src/layout/RecordApprovalModal/ApprovalModal.module.css, toga25-supply/src/utils/formatDateTime.ts, toga25-supply/src/utils/index.ts, toga25-supply/src/surface/evaluateSurfaceRule.ts, toga25-supply/src/layout/RecordApprovalModal/, toga25-supply/src/contexts/AuthContext.tsx, toga25-supply/src/surface/applyColSpan.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/sectionRenderers.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getVisibleSections.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/SalesOrderApprovalSummaryGrid.tsx, toga25-supply/src/surface/useFetchSurfaceMeta.ts, toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/VendorItems/viewModel/useVendorItemsPageViewModel.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/pages/Bundles/viewModel/useBundlesPageViewModel.tsx, toga25-supply/src/layout/ItemFulfillmentModal/useItemFulfillmentModalViewModel.tsx, toga25-supply/src/pages/SalesOrders/helpers/surfaceBundleToTenantFields.ts, toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/ServiceRequestRecordModalLayout.tsx, toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/view/ServiceRequestsView.tsx, toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/viewModel/useServiceRequestRecordModalLayoutModel.tsx, toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/viewModel/FIELDS/apiFields.json, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderTopBar.tsx, toga25-supply/src/pages/SalesOrders/helpers/buildPatchedTenantFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/SalesOrderView.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/SalesOrderSummaryGrid.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getDetailSections.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderNotesSection.tsx, toga25-supply/src/pages/SalesOrders/helpers/cleanOrder.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/AdminNotesSection.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getAdminNotes.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/index.ts, toga25-supply/src/surface/evaluateSurfaceRule.ts, toga25-supply/src/surface/resolveElementState.ts, toga25-supply/src/pages/SalesOrders/helpers/evaluateEnableRule.ts, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrderRowRecordState.ts, toga25-supply/src/surface/actionRegistry.ts, toga25-supply/src/surface/componentRegistry.tsx, toga25-supply/src/surface/SurfaceActionBar.tsx, toga25-supply/src/surface/SurfaceSection.tsx, toga25-supply/src/surface/resolve.ts, toga25-supply/src/surface/types.ts, toga25-supply/src/surface/index.ts, toga25-supply/src/pages/Login/LoginPage.tsx, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx, toga25-supply/src/pages/SalesOrders/view/SurfaceRowActions.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrderVip.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderTopBar.tsx, toga25-supply/src/pages/Items/ItemsPage.tsx, toga25-supply/src/pages/Items/viewModel/useItemsPageViewModel.tsx, toga25-supply/src/pages/VendorItems/VendorItemsPage.tsx, toga25-supply/src/pages/VendorItems/viewModel/useVendorItemsPageViewModel.tsx, toga25-supply/src/pages/Inventory/Inventory.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/pages/Inventory/viewModel/FIELDS/index.ts, toga25-supply/src/fieldsConfig/index.ts, toga25-supply/src/layout/ItemRecordModalLayout/helpers/surfaceBundleToItemFields.ts, toga25-supply/src/layout/ItemRecordModalLayout/helpers/index.ts, toga25-supply/src/layout/ItemRecordModalLayout/viewModel/useItemRecordModalViewModel.tsx, toga25-supply/src/layout/ItemRecordModalLayout/ItemRecordModalLayout.tsx, toga25-supply/src/layout/ItemRecordModalLayout/components/ItemRecordView.tsx, toga25-supply/src/surface/useStatusColors.ts, toga25-supply/src/surface/SurfaceHeader.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/helpers/surfaceBundlesToDecisionFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/viewModel/useApprovalModalViewModel.tsx |
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-03
9
+ updated: 2026-09-04
10
10
  owners: [apeterson]
11
11
  files:
12
12
  - toga25-supply/src/components/ColumnVisibilityModal/
@@ -187,6 +187,11 @@ leaves a clean URL, and a column added to the meta later still shows up for that
187
187
 
188
188
  ## Gotchas
189
189
 
190
+ - **⚠ The six-page `useColumnState` migration cannot be reverted file-by-file.** The shared
191
+ `ColumnVisibilityModalLabels` type gained **required** fields (`shown`, `hidden`, `lockedTip`,
192
+ `pinnedTip`), so restoring an old call site no longer type-checks — the build breaks. Roll the
193
+ whole change back together or not at all. (The dead `columnsButton` return in the SalesOrders view
194
+ model was deleted in the same pass.)
190
195
  - **No hardcoded copy in the component** — every user-facing string (incl. Close `aria-label`) is a
191
196
  required `labels` prop. On the **legacy** path the fallbacks live in the view model's resolution
192
197
  (never in the component or default props); on the **surface** path there are **no fallbacks at
@@ -211,6 +216,11 @@ leaves a clean URL, and a column added to the meta later still shows up for that
211
216
  meta later won't appear for an old link until Reset (intended).
212
217
 
213
218
  ## Change history
219
+ - 2026-09-04 — ⚠ Recorded that the six-page `useColumnState` migration is **not reversible
220
+ file-by-file**: the shared `ColumnVisibilityModalLabels` type gained required fields (`shown`,
221
+ `hidden`, `lockedTip`, `pinnedTip`), so an old call site no longer type-checks and a partial revert
222
+ breaks the build. Roll it back whole or not at all. The dead `columnsButton` return in the
223
+ SalesOrders view model was removed in the same pass. (apeterson)
214
224
  - 2026-09-03 — The modal became **its own MODAL Surface** (`columns-modal`, **Core 60**; Messages
215
225
  293–298, SurfaceElements 222–232, `Core/2026-09-03c`). The 08-28 approach was unworkable: the
216
226
  resolver only collects messages from `*MessageId` columns and **never scans `config`**, so the
@@ -19,6 +19,7 @@ files:
19
19
  - toga25-supply/src/layout/SalesOrderRecordModalLayout/
20
20
  - toga25-supply/src/layout/SalesOrderItemsTableLayout/
21
21
  - toga25-supply/src/layout/ItemFulfillmentModal/
22
+ - toga25-supply/src/layout/ItemFulfillmentModal/useItemFulfillmentModalViewModel.tsx
22
23
  - toga25-supply/src/layout/GenericNestedTables/GenericNestedTables.tsx
23
24
  - toga25-supply/src/layout/GenericNestedTables/GenericTableLayout.tsx
24
25
  - toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx
@@ -110,6 +111,29 @@ navigation). `ItemFulfillmentModal` sets it because the fulfillment table is rea
110
111
  scoped so all other `PrimaryTableServerLayout` consumers keep normal row-click behavior — pass it
111
112
  only for genuinely read-only tables.
112
113
 
114
+ ### ⚠ `ItemFulfillmentModal`'s `slug` prop IS a TableView slug — and it is still hardcoded to sales orders
115
+
116
+ The shared "Qty Fulfilled" drill-down modal (`src/layout/ItemFulfillmentModal/`) reads as if `slug`
117
+ were a component variant. It is not: **`slug` is a TableView slug**, and the whole modal is built
118
+ from it — `useTablePageMeta({ slug })` supplies the columns and
119
+ `useTableData({ slug, tableViewMeta })` supplies the rows
120
+ (`useItemFulfillmentModalViewModel.tsx`).
121
+
122
+ **No TableView row for that slug → the modal opens EMPTY.** No error, no failed request. Reusing this
123
+ modal for a new record type is therefore a **migration** first, a front-end change second.
124
+
125
+ **Two spots are still hardcoded to sales orders** (blocking reuse as of 2026-09-04):
126
+
127
+ 1. **line ~27** — the label fetch always asks for page meta `"item-fulfillments-for-sales-orders"`,
128
+ whatever slug was passed in.
129
+ 2. **lines ~55–60** — the record filter falls back to **`"SalesOrders.uuid"`** for any unrecognised
130
+ slug, so a non-sales-order slug filters on the wrong column and returns nothing.
131
+
132
+ **Measured in `Client_Nychh`: only TableViews 12 (`item-fulfillments-for-sales-orders`) and 13
133
+ (`item-fulfillments-for-sales-order-items`) exist, both on `recordId` 29.** There is no
134
+ transfer-order equivalent in any environment — see
135
+ [transfer-orders-page](./transfer-orders-page.md).
136
+
113
137
  ## Pattern 5 — `GenericNestedTables` (config-driven multi-level / swap tables)
114
138
 
115
139
  A config-driven renderer: declare an array of `TableLevel` objects instead of hand-wiring each
@@ -343,6 +367,14 @@ Scaffold per the `ItemRecordModalLayout` pattern (`src/layout/ItemRecordModalLay
343
367
  EDIT is still JSON**: a field visible only in edit mode means a missing client `SurfaceOverride`,
344
368
  not a data bug (found on Compass's "Restrict to Persona" field). Detail in
345
369
  [surface-frontend](surface-frontend.md). (tcox)
370
+ - 2026-09-04 — ⚠ Recorded that **`ItemFulfillmentModal`'s `slug` prop is a TableView slug**, not a
371
+ component variant: `useTablePageMeta`/`useTableData` build the entire modal from it, so a missing
372
+ TableView row opens the modal **empty and silently**. Reusing it for a new record type is a
373
+ migration before it is a front-end change. Also flagged the **two spots still hardcoded to sales
374
+ orders** — the label fetch always requests `item-fulfillments-for-sales-orders` (line ~27) and the
375
+ record filter falls back to `SalesOrders.uuid` for any unrecognised slug (lines ~55–60), so a
376
+ transfer-order slug today filters the wrong column and returns nothing. `Client_Nychh` has only
377
+ TableViews 12/13, both on `recordId` 29; no transfer-order equivalent exists anywhere. (apeterson)
346
378
  - 2026-09-03 — Added the **deep-link-from-a-modal-into-a-nested-table** pattern (pin `grouping`;
347
379
  build the column-filter chip with the `useServerTableUrlState` serializer, keyed by the table-meta
348
380
  **column id**; set `<slug>=<uuid>` to pre-expand the row) and the rule that makes it work:
@@ -7,7 +7,7 @@ client: shared
7
7
  type: feature
8
8
  status: active
9
9
  updated: 2026-09-04
10
- owners: [jcardinal, apeterson, tcox]
10
+ owners: [jcardinal, apeterson, tcox, rgirish]
11
11
  files:
12
12
  - toga25-supply/src/layout/RecordApprovalModal/helpers/stackedCurrencyJoiner.ts
13
13
  - toga25-supply/src/layout/RecordApprovalModal/helpers/stackedCurrencyJoiner.test.ts
@@ -242,6 +242,28 @@ field even though its API returned the data. **It has been removed entirely.**
242
242
  `FIELDS[clientSlug]`) are the **legacy JSON fallback layer, NOT surface gating**, and are correctly left
243
243
  in place (they serve clients the backend doesn't yet drive via Surface).
244
244
 
245
+ ### ⚠ Corollary — NEVER resolve which table view a client's page uses by grepping `src`
246
+
247
+ Because surface is backend-driven, the repo's grouping JSON is only the **DEFAULT**. A client's
248
+ `SurfaceOverrides` can repoint a level at a completely different `fetchSlug`, and **that slug may
249
+ appear nowhere in `src`** — which reads to a developer as "that view is dead code." It is not.
250
+
251
+ **Rule: query that client's `SurfaceOverrides` in the target environment. A `grep` of the frontend
252
+ repo cannot answer "which view does this client's page read?"**
253
+
254
+ Worked case (Elite Inventory, prod, 2026-09-04):
255
+ `src/pages/Inventory/viewModel/FIELDS/DEFAULT/inventoryGroupings.json` uses `inventory_items` and
256
+ `units-for-items-for-purchase-orders`, and **`inventory_units` appears nowhere in `src`**. But
257
+ `Client_Elite.SurfaceOverrides` element **120** CONFIG is
258
+ `{"key":"units","levels":[{"fetchSlug":"inventory_units","role":"flat"}]}` and element **118**
259
+ (`unitsByItems`) also points at `inventory_units`, with 116/117 `IS_VISIBLE 0`. So Elite's live
260
+ Units view is `inventory_units` (`TableViews` id 21), **not** id 17 — and a row filter applied to
261
+ view 17 would have had no visible effect. See
262
+ [Elite tableview config drift](../../../clients/elite/features/supply2-tableview-config-drift.md).
263
+
264
+ This pairs with the standing rule that **a committed migration records intent, not deployed
265
+ state** — the frontend repo records the default, not this tenant's state.
266
+
245
267
  ## SalesOrders record-modal sections — migrated via the registered-renderer seam
246
268
 
247
269
  The SalesOrders detail sections are migrated onto Surface via the registered-renderer approach (not a
@@ -783,8 +805,45 @@ in an edit to the shared blox stylesheet.
783
805
  ⚠ This is **shared infra** — `SurfaceActionBar` renders every migrated screen's chrome, so a change
784
806
  here is a cross-screen change. Re-check Sales Orders, Items and Vendor Items after touching it.
785
807
 
808
+ ## Hidden elements ARRIVE in the bundle — every hand-rolled builder must filter on `isVisible`
809
+
810
+ The resolver ships the **whole** element tree and marks state on it; it does **not** prune. So a
811
+ component that maps `bundle.elements` to its own controls **renders elements the Core seed
812
+ deliberately turned off** unless it checks `el.isVisible` itself. `SurfaceActionBar` does this for
813
+ you — a page that builds its own header buttons does not.
814
+
815
+ Found 2026-09-03 on the **Inventory** page: `useInventoryPageViewModel` built its header buttons
816
+ without the filter. It had no symptom only because no hidden element existed on that bar yet; the
817
+ moment `Core/2026-09-03b` seeded the Create Transfer Order button `isVisible = 0`, it would have
818
+ appeared for **every tenant** — defeating the whole Core-neutral-default / tenant-opt-in model. See
819
+ [transfer-orders-page](./transfer-orders-page.md).
820
+
821
+ > **Check for this wherever a view model reads `elements` directly.** `isVisible = 0` is the platform's
822
+ > off switch; ignoring it turns every neutral default into an accidental release.
823
+
824
+ ## `useStatusColors` is keyed by `{ route, keyField }` — vocabularies do NOT all key on slug
825
+
826
+ The original hook mapped a vocabulary slug to an API **route** and assumed the term `value` was
827
+ always the record's **slug**. That held for sales orders and breaks for transfer orders:
828
+ `TransferOrderStages` has no slug column, so its `VocabularyTerms.value` holds the stage **NAME**.
829
+
830
+ `src/surface/useStatusColors.ts` therefore keys each vocabulary to a **`{ route, keyField }` pair**,
831
+ and matches the term value against that field of the fetched rows
832
+ (`?fields=slug,name,colorHex`). Adding a third STATUS vocabulary means answering *"what does its
833
+ `VocabularyTerms.value` actually hold?"* before wiring it — a wrong `keyField` produces a badge with
834
+ the right label and no colour, and logs nothing.
835
+
836
+ Full badge chain (`RecordFields.type='STATUS'` → `Records.statusRecordId` → route → typeValues →
837
+ blox `StatusCell`, which is a plain `typeValues[value]` lookup):
838
+ [transfer-orders-page](./transfer-orders-page.md).
839
+
786
840
  ## Gotchas
787
841
 
842
+ - **⚠ Hidden elements are IN the bundle** — a hand-rolled control builder that ignores
843
+ `el.isVisible` ships every element the Core seed turned off (see the section above).
844
+ - **⚠ A STATUS badge renders label-but-no-colour when the vocabulary's `keyField` is wrong** —
845
+ `StatusCell` is a plain map lookup on the cell value, so a name against a slug-keyed vocabulary
846
+ just misses, silently.
788
847
  - **A vitest file that reaches `@agilant/toga-blox` must stub the whole package:
789
848
  `vi.mock("@agilant/toga-blox", () => ({}))`.** blox's `dist` entry imports `global.css`, which
790
849
  node's ESM loader rejects, so **any** import chain that transitively pulls blox in fails to load
@@ -1055,6 +1114,22 @@ unused).
1055
1114
  "Unexpected Application Error!" because the app has no top-level `ErrorBoundary`.
1056
1115
  `formatPersonaItems` now accepts both value shapes and is registered under both keys. **Deploy the
1057
1116
  FE first or together — the SQL alone crashes the modal.** (tcox)
1117
+ - 2026-09-04 — Two shared-infra findings from the transfer-order status badge. (1) ⚠ **Hidden
1118
+ elements arrive in the resolved bundle** — the resolver marks state, it never prunes — so any view
1119
+ model that builds its own controls from `bundle.elements` must filter on `el.isVisible`. The
1120
+ Inventory header-button builder did not; it was symptomless only until `Core/2026-09-03b` seeded a
1121
+ deliberately-hidden button, which would then have shown for every tenant. (2) **`useStatusColors`
1122
+ is now keyed by `{ route, keyField }`, not by route alone**: sales-order terms carry the status
1123
+ **slug** but transfer-order terms carry the stage **NAME** (`TransferOrderStages` has no slug
1124
+ column), and a wrong key field yields a correctly-labelled badge with no colour and no log line.
1125
+ (apeterson)
1126
+ - 2026-09-04 — Added the diagnostic corollary to "surface is 100% backend-driven": **a client's
1127
+ live `fetchSlug` may appear nowhere in `src`, so grepping the frontend repo cannot tell you which
1128
+ table view a client's page reads — query that client's `SurfaceOverrides` in the target
1129
+ environment.** Worked case: Elite's Inventory Units level resolves to `inventory_units`
1130
+ (`TableViews` 21) via prod `SurfaceOverrides` elements 120/118, while the repo DEFAULT JSON names
1131
+ `units-for-items-for-purchase-orders` (17) and `inventory_units` is absent from `src` entirely.
1132
+ No code change. (rgirish)
1058
1133
  - 2026-09-03 — Recorded that the 2.5 app **still calls `GET /v2/pages/meta`** from five view
1059
1134
  models (SalesOrders — twice per load — VendorItems, Inventory, Bundles, ItemFulfillmentModal)
1060
1135
  even though nothing in `src/` reads a field off the result. Found by the new Cypress
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-03
9
+ updated: 2026-09-04
10
10
  owners: [apeterson]
11
11
  files:
12
12
  - toga25-supply/src/components/ToolbarButton/ToolbarButton.tsx
@@ -64,6 +64,24 @@ than by luck with whatever font metrics resolve.
64
64
  **The "applied" signal never fills the button.** Instead the glyph moves into a small filled rounded
65
65
  square — Group by: 18×18, radius 4, `#3F5DCA`, white glyph.
66
66
 
67
+ ### The API — `shape` + `activeAppearance`, generalised from `ColumnsToolbarButton`
68
+
69
+ `ToolbarButton` was **generalised out of the one-off `ColumnsToolbarButton`**, not written fresh, so
70
+ the Columns button is the reference call site.
71
+
72
+ - **`shape`** picks between the two design shapes above: **`pill`** for actions (Refresh, Reset),
73
+ **`chip`** for management controls (Columns, Group by). One component, two shapes — not two
74
+ components.
75
+ - **`activeAppearance`** separates the **transient** active state (pressed / in-flight) from the
76
+ **persistent** one (a filter or grouping is currently applied). They look different by design and
77
+ had previously been conflated, so a button could look permanently "on" after a single click.
78
+
79
+ ### Toolbar spacing — `gap-x-2` (8px), not `gap-x-5` (20px)
80
+
81
+ Three labeled-mode pages (Transfer Orders, Sales Orders, Inventory) shipped with `gap-x-5` = **20px**
82
+ between toolbar buttons; the mockup is **8px**. Corrected to `gap-x-2` on all three. Worth stating
83
+ explicitly because 20px looks deliberate rather than wrong until it is next to the design.
84
+
67
85
  **Buttons the design does not specify keep `BaseButton`:** PRIMARY create actions, and the Approvals
68
86
  filter button (SurfaceElement 22, `config.role = filterButton`), whose navy fill is a clearer "on"
69
87
  signal than a chip.
@@ -84,6 +102,13 @@ signal than a chip.
84
102
  `Columns (N)` label when the columns differ from default.
85
103
 
86
104
  ## Change history
105
+ - 2026-09-04 — Recorded the component's actual API and the spacing fix: `ToolbarButton` was
106
+ **generalised out of `ColumnsToolbarButton`** and takes a **`shape`** prop (`pill` for actions,
107
+ `chip` for management controls) plus an **`activeAppearance`** prop that separates a **transient**
108
+ active state (pressed/in-flight) from a **persistent** one (a filter or grouping is applied) —
109
+ previously conflated, which left a button looking permanently on. Also corrected the toolbar gap
110
+ from `gap-x-5` (20px) to **`gap-x-2` (8px)** on the three labeled-mode pages (Transfer Orders,
111
+ Sales Orders, Inventory) to match the mockup. (apeterson)
87
112
  - 2026-09-03 — Created. A page toolbar rendered three buttons at three different heights; the cause is
88
113
  that blox `.secondaryBtnAction` hard-codes `height: 26px` with **no theme token**, while
89
114
  `.primaryBtn` sets no height at all. Rethemeing would hit every button in the app, so the toolbar
@@ -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)_ — 24 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
7
+ - **library** (Library) _(framework core)_ — 25 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
8
  - **worker** (Worker) — 34 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)
@@ -2,10 +2,10 @@
2
2
 
3
3
  | Doc | Framework | Summary | Files |
4
4
  |-----|-----------|---------|-------|
5
- | [Elite — NetSuite → TOGa Supply inbound sync (TRUE-80499 onboarding)](features/netsuite-togasupply-sync.md) | 1.0 | Elite is the 18th client on the shared NetSuite → TOGa Supply importer ([engine](../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md)). | worker/crons/toga2/netsuite/sync_togasupply_elite.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/schedules/cron.worker.sync.json, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/PurchaseOrder.php, _underscore/Model/Elite/ItemReceipt.php, dbchanges2/Client_Elite/_modules.txt, test/@srija/Elite Testing/Service Requests/test_sync_togasupply_elite_section.php, test/@srija/Elite Testing/Service Requests/test_diagnose_togasupply_elite.php |
5
+ | [Elite — NetSuite → TOGa Supply inbound sync (TRUE-80499 onboarding)](features/netsuite-togasupply-sync.md) | 1.0 | Elite is the 18th client on the shared NetSuite → TOGa Supply importer ([engine](../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md)). | worker/crons/toga2/netsuite/sync_togasupply_elite.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/schedules/cron.worker.sync.json, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/PurchaseOrder.php, _underscore/Model/Elite/ItemReceipt.php, dbchanges2/Client_Elite/_modules.txt, dbchanges2/Client_Elite/2026-09-04a - EliteItemAssetTypeApiWritePermission.sql, library/app/api/toga2.php, library/app/api/netsuite/rest.php, test/@srija/Elite Testing/Service Requests/test_sync_togasupply_elite_section.php, test/@srija/Elite Testing/Service Requests/test_diagnose_togasupply_elite.php |
6
6
  | [Elite SalesOrder → NetSuite Push (postPost/postPut interceptors → worker2)](features/salesorder-netsuite-push.md) | 2.0 | Elite orders created in Toga are pushed into NetSuite **event-driven**, not on a cron. | _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/ServiceRequest.php, worker2/Worker/Netsuite/SalesOrder.php, dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql |
7
7
  | [Elite — Sales Order stage change posts a reply on the TOGa Desk (1.0) ticket](features/salesorder-status-togadesk-reply.md) | 2.0 | When an Elite sales order's **stage** changes, a reply is posted on the originating **TOGa Desk (1.0)** ticket so the requester sees progress where they raised | worker2/Worker/Sync/SalesOrderStatus.php, _underscore/Model/Elite/SalesOrderStatus.php, _underscore/Model/Elite/SalesOrder.php |
8
8
  | [Elite — supply2 frontend scope (Inventory + Service Requests, both built)](features/supply2-scope.md) | 2.0 | Scope for onboarding Elite to the `toga2-supply` frontend (host `ELITE`). | toga2-supply/ELITE-CLIENT-TASK-NOTES.md, toga2-supply/src/pages/Orders/view/OrderView/viewModel/FIELDS/ELITE/BASEFIELDS.json, toga2-supply/src/pages/Orders/viewModel/FIELDS/ELITE/BASE.json, toga2-supply/src/pages/Inventory/viewModel/FIELDS/DUMMYGROUPOPTIONS.ts, toga2-supply/src/hooks/useFetchData.tsx, toga2-supply/src/components/ui/Toaster.tsx, toga2-supply/src/pages/Inventory/viewModel/FIELDS/DUMMYGROUPOPTIONS.ts, toga2-supply/src/pages/Inventory/viewModel/FIELDS/INVENTORYPAGEFIELDS.ts, toga2-supply/src/pages/Inventory/viewModel/index.ts, toga2-supply/src/pages/Inventory/listing/InventoryPage.tsx, toga2-supply/src/pages/Inventory/listing/InventoryRouter.tsx, toga2-supply/src/pages/Inventory/listing/InventorySubTablePage.tsx, toga2-supply/src/utils/resolveClientHostName.ts, toga2-supply/src/utils/formatCustomerName.tsx, toga2-supply/src/utils/convertConstructorColumnTitles.ts, toga2-supply/src/pages/Orders/OrdersPage.tsx, toga2-supply/src/pages/Orders/viewModel/FIELDS/ELITE/BASE.json, toga2-supply/src/pages/Orders/viewModel/useOrdersPageViewModel.ts, toga2-supply/src/pages/Orders/api/OrdersApi.ts, toga2-supply/src/pages/Orders/view/OrderView/viewModel/useOrderDetailsViewModel.ts, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/renderModalContent.tsx, toga2-supply/src/components/layout/SlideMenu/SlideMenu.tsx, toga2-supply/package.json, dbchanges2/Client_Elite/2026-08-18 - InventoryUnitsItemColumns.sql |
9
- | [Elite — stale TableView config (11 dead Core.RecordFields across 9 views; 2 now fixed)](features/supply2-tableview-config-drift.md) | 2.0 | `Client_Elite`'s `TableViewJoins` predate **two** platform bridge-table migrations and still reference **11 deleted `Core.RecordFields` ids (211, 321, 932, 358, | dbchanges2/Client_Elite/, dbchanges2/Client_Elite/2026-08-07 - ServiceRequests TableView.sql, dbchanges2/Client_Elite/2026-08-17 - FulfillmentTableViews.sql, dbchanges2/Core/2026-08-07 - ServiceRequests TableView.sql, dbchanges2/Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql, dbchanges2/Client_Nychh/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql |
9
+ | [Elite — stale TableView config (11 dead Core.RecordFields across 9 views; 2 now fixed)](features/supply2-tableview-config-drift.md) | 2.0 | `Client_Elite`'s `TableViewJoins` predate **two** platform bridge-table migrations and still reference **11 deleted `Core.RecordFields` ids (211, 321, 932, 358, | dbchanges2/Client_Elite/, dbchanges2/Client_Elite/2026-08-07 - ServiceRequests TableView.sql, dbchanges2/Client_Elite/2026-08-17 - FulfillmentTableViews.sql, dbchanges2/Client_Elite/2026-08-13a - InventoryGroupingsSurfaceOverrides.sql, dbchanges2/Client_Elite/2026-09-04b - EliteInventoryHideServiceItems.sql, dbchanges2/Core/2026-08-07 - ServiceRequests TableView.sql, dbchanges2/Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql, dbchanges2/Client_Nychh/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql |
10
10
  | [Elite — raising a Service Request from a TOGa Desk ticket (App_Api_ServiceRequest)](features/togadesk-service-request-intake.md) | 1.0 | An Elite agent raises a **Service Request** from a TOGa Desk (1.0) ticket via a modal. | library/app/api/servicerequest.php, library/app/model/togadesk/ticket.php, togadesk/desk/includes/classes/class.ticket.php, togadesk/desk/template/modals/tickets/serviceRequest.php, togadesk/desk/includes/controllers/modals/tickets/serviceRequest.php, togadesk/desk/includes/controllers/actions/tickets/serviceRequest.php, dbchanges2/Client_Elite/2026-08-11a - EliteServiceRequestTicketUnique.sql, test/@srija/Elite Testing/Service Requests/test_elite_desk_service_request.php |
11
11
  | [Elite](profile.md) | 2.0 | Elite is a managed-services client that uses **Freshservice** as their helpdesk platform. | worker2/Worker/Elite.php, worker2/Worker/Sync/ServiceRequest.php, library/app/api/toga2.php, library/app/api/servicerequest.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/ServiceRequest.php, togadesk/desk/includes/classes/class.ticket.php, worker/crons/toga2/netsuite/sync_togasupply_elite.php |
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: elite
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-03
9
+ updated: 2026-09-04
10
10
  owners: ["snaredla", "jcardinal", "rgirish"]
11
11
  files:
12
12
  - worker/crons/toga2/netsuite/sync_togasupply_elite.php
@@ -16,10 +16,14 @@ files:
16
16
  - _underscore/Model/Elite/PurchaseOrder.php
17
17
  - _underscore/Model/Elite/ItemReceipt.php
18
18
  - dbchanges2/Client_Elite/_modules.txt
19
+ - dbchanges2/Client_Elite/2026-09-04a - EliteItemAssetTypeApiWritePermission.sql
20
+ - library/app/api/toga2.php
21
+ - library/app/api/netsuite/rest.php
19
22
  - test/@srija/Elite Testing/Service Requests/test_sync_togasupply_elite_section.php
20
23
  - test/@srija/Elite Testing/Service Requests/test_diagnose_togasupply_elite.php
21
24
  related:
22
25
  - ../profile.md
26
+ - ../../../1.0/apps/library/features/netsuite-item-assettype-sync.md
23
27
  - ../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md
24
28
  - ../../../1.0/apps/worker/workflows/onboarding-client-to-netsuite-togasupply-sync.md
25
29
  - ./salesorder-netsuite-push.md
@@ -81,11 +85,16 @@ the trait declares must exist as a column, a `CustomRecordFields` row **and** an
81
85
  other 17 wrappers inherit — Elite is backfilling from 2024 and 5-day windows on a 5-minute cron were
82
86
  too slow. This is only possible because the shared engine now `define()`s the cap instead of
83
87
  `const`-ing it. **Reduce it to the default once the backfill is caught up.**
84
- - **`IS_ENABLED_INTEGRATION_INVOICES = false` — TEMPORARY.** The invoice section scans the whole
85
- NetSuite transaction table per window, kept running out of time, and **starved the item-receipt and
86
- item-fulfillment sections that follow it** which are what `_qtyOnHand` derives from. Re-enable
87
- once inventory has caught up. (Note the invoice section is also capped at `now 86400s` by design,
88
- so it always trails.)
88
+ - **⚠ EVERY integration flag is now `true` (2026-09-04) including invoices, and that carries a
89
+ live risk.** `_SALES_ORDERS`, `_INVOICES`, `_ITEM_RECEIPTS`, `_ITEM_FULFILLMENTS`,
90
+ `_INVENTORY_ADJUSTMENTS` and `_TRANSFER_ORDERS` were all flipped `false true`
91
+ (`_PURCHASE_ORDERS` was already on); stale comments were removed, including one that still said
92
+ transfer orders were off. **Invoices was deliberately disabled** because its section scans the
93
+ whole NetSuite transaction table per window, kept running out of time, and **starved the
94
+ item-receipt and item-fulfillment sections that follow it** — the ones `_qtyOnHand` derives from.
95
+ It is back on **at the same time as a planned PO cursor backfill**, so the starvation risk is
96
+ live. **If item receipts stop advancing, invoices is the first suspect.** (The invoice section is
97
+ also capped at `now − 86400s` by design, so it always trails.)
89
98
  - **`MIN_DATETIME_TO_CHECK_FOR_NETSUITE_DATA = '2018-01-01'`**, with the real start controlled by the
90
99
  seeded `Parameters` cursors.
91
100
  - **`TRANSFER_ORDER_DETECTION_MODE` is pre-set but NOT in use (2026-09-03).** Elite's wrapper declares
@@ -96,6 +105,54 @@ the trait declares must exist as a column, a `CustomRecordFields` row **and** an
96
105
  transfer orders as $0 held sales orders; do not assume the pre-set mode is correct for them. Mode
97
106
  mechanics: [per-client sync](../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md).
98
107
 
108
+ ### Elite is the ONLY client opted in to assetType stamping
109
+
110
+ Elite's wrapper defines `NETSUITE_ITEM_TYPE_TO_ASSET_TYPE_NAME`, which turns on
111
+ [assetType stamping from NetSuite `itemtype`](../../../1.0/apps/library/features/netsuite-item-assettype-sync.md).
112
+ No other launcher defines it, so the other ~19 clients send no `assetType` key at all.
113
+
114
+ ```
115
+ 'InvtPart' => 'Laptop', 'Kit' => 'Laptop', 'Group' => 'Laptop',
116
+ 'Service' => 'Services', 'NonInvtPart' => 'Services', 'OthCharge' => 'Services',
117
+ 'Discount' => 'Services', 'Expense' => 'Services', 'Description' => 'Services'
118
+ ```
119
+
120
+ `Client_Elite.AssetTypes` has exactly **2 rows: 1 = `Laptop`, 2 = `Services`.** The non-inventory
121
+ types are in the map on purpose — `itemtype = 'Service'` alone marks only **11 of ~28** real Elite
122
+ services, because `NonInvtPart` is mixed (`SVC-FS-DEPLOY`, `SVC-FS-SHIPPING-*`,
123
+ `SVC-CI-RETAINER-RS`, `SVC-TS-Removal`, `Project - Cabling`). Elite's 113 items break down as
124
+ 65 InvtPart / 11 Service / 17 NonInvtPart.
125
+
126
+ **Two deploy prerequisites, in this order:**
127
+
128
+ 1. **`dbchanges2/Client_Elite/2026-09-04a - EliteItemAssetTypeApiWritePermission.sql` must run
129
+ BEFORE the library/worker deploy.** It grants `AclFieldPermissions` write on recordFieldId
130
+ **269** (`Items.assetTypeId`) to **roleId 3 (API)**, which previously held roleId 1 only. api2
131
+ fails the **entire** item write on a non-writable field, so deploying the code first breaks item
132
+ creation for Elite. **Applied in production and re-verified** (269 now carries roleId 1 and 3).
133
+ 2. **A cursor rollback is required to stamp existing items.** `getCreateItem()` only runs when a
134
+ transaction line references an item, so Elite's **110 already-NULL items stay NULL** until the
135
+ sync re-walks transactions that reference them. Cursors are in `Client_Elite.Parameters`
136
+ (`NETSUITE_LAST_SYNC_CURSOR_*`, value `"<lastmodifieddate>|<netsuiteInternalId>"`).
137
+
138
+ Prod cursor state at the time of this work, `NETSUITE_EXECUTION_MODE_*` all `IDLE`:
139
+
140
+ | Section | Cursor |
141
+ |---|---|
142
+ | PURCHASE_ORDERS (the only section then enabled) | `2026-09-04 12:56:00\|0` |
143
+ | SALES_ORDERS | `2026-09-01 15:45:56\|0` |
144
+ | ITEM_RECEIPTS | `2026-09-01 16:04:00\|0` |
145
+ | ITEM_FULFILLMENTS | `2026-09-01 16:04:11\|0` |
146
+ | INVOICES | `2026-08-20 00:00:00\|0` |
147
+ | INVENTORY_ADJUSTMENTS | `2026-09-01 16:04:21\|0` |
148
+
149
+ **3 Elite items are typed `InvtPart` in NetSuite but are really services** —
150
+ `SVC-TS-ELITE-HDONBOARDING`, `CONFIG/INSTALL`, `CONF-RM-INSTALL-SUPP`. **Decided: a NetSuite
151
+ data-hygiene ask for the client, not a code special case.** Stamping is fill-NULL-only, so a manual
152
+ TOGa correction survives later syncs. The Inventory filter also keeps a `SVC-` part-number prefix
153
+ rule that still catches these three — see
154
+ [supply2-tableview-config-drift](./supply2-tableview-config-drift.md).
155
+
99
156
  ## Gotchas / known issues
100
157
 
101
158
  - **⚠ Two files named `sync_togasupply_elite.php`, both scheduled.**
@@ -132,6 +189,22 @@ the trait declares must exist as a column, a `CustomRecordFields` row **and** an
132
189
 
133
190
  ## Change history
134
191
 
192
+ - 2026-09-04 — **Elite became the first (and only) client opted in to assetType stamping**, and
193
+ **all integration flags were switched on.** Added `NETSUITE_ITEM_TYPE_TO_ASSET_TYPE_NAME` to the
194
+ wrapper (InvtPart/Kit/Group → `Laptop`; Service/NonInvtPart/OthCharge/Discount/Expense/Description
195
+ → `Services`) — the non-inventory types are needed because `NonInvtPart` is mixed and
196
+ `itemtype = 'Service'` alone catches only 11 of ~28 real services. Shipped
197
+ `2026-09-04a - EliteItemAssetTypeApiWritePermission.sql` (recordField **269** write grant to
198
+ **roleId 3**; applied and re-verified in prod) which **must run before the library/worker
199
+ deploy**, since api2 fails the whole item write on a non-writable field. Elite's 110 NULL items
200
+ need a **PO cursor rollback** to be stamped — `getCreateItem()` only fires on a referenced
201
+ transaction line; prod cursor state recorded above. Flipped `_SALES_ORDERS`, `_INVOICES`,
202
+ `_ITEM_RECEIPTS`, `_ITEM_FULFILLMENTS`, `_INVENTORY_ADJUSTMENTS`, `_TRANSFER_ORDERS` to `true` and
203
+ removed stale comments — **note the invoices risk**: it was disabled for starving the
204
+ receipt/fulfillment sections, and it is back on alongside the cursor backfill. Engine mechanics
205
+ and the two platform blockers:
206
+ [assetType sync](../../../1.0/apps/library/features/netsuite-item-assettype-sync.md). (rgirish)
207
+
135
208
  - 2026-09-03 — Elite's wrapper now declares `TRANSFER_ORDER_DETECTION_MODE =
136
209
  App_Api_Toga2::TRANSFER_ORDER_DETECTION_MODE__ZERO_DOLLAR_HOLD` as part of the shared
137
210
  transfer-order detection-mode refactor (the old `IS_ENABLED_TRANSFER_ORDER_STOCKING_FLAG_ROUTING`
@@ -6,17 +6,22 @@ project: Database Changes
6
6
  client: elite
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-18
10
- owners: [tcox, bala]
9
+ updated: 2026-09-04
10
+ owners: [tcox, bala, rgirish]
11
11
  files:
12
12
  - dbchanges2/Client_Elite/
13
13
  - dbchanges2/Client_Elite/2026-08-07 - ServiceRequests TableView.sql
14
14
  - dbchanges2/Client_Elite/2026-08-17 - FulfillmentTableViews.sql
15
+ - dbchanges2/Client_Elite/2026-08-13a - InventoryGroupingsSurfaceOverrides.sql
16
+ - dbchanges2/Client_Elite/2026-09-04b - EliteInventoryHideServiceItems.sql
15
17
  - dbchanges2/Core/2026-08-07 - ServiceRequests TableView.sql
16
18
  - dbchanges2/Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql
17
19
  - dbchanges2/Client_Nychh/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql
18
20
  related:
19
21
  - ./supply2-scope.md
22
+ - ./netsuite-togasupply-sync.md
23
+ - ../../../2.0/apps/api2/features/tableview-apiwhereclause-row-filtering.md
24
+ - ../../../2.0/apps/toga25-supply/features/surface-frontend.md
20
25
  - ../../../2.0/apps/_underscore/features/tracking-number-bridges.md
21
26
  - ../../../2.0/apps/_underscore/features/units-for-items-for-purchase-orders.md
22
27
  - ../../../2.0/apps/_underscore/features/page-meta-context-field-settings.md
@@ -241,6 +246,61 @@ Do not revive this without first re-testing finding 2 above.
241
246
 
242
247
  </details>
243
248
 
249
+ ## ⚠⚠ Which table view Elite's Inventory page uses comes from `SurfaceOverrides` — NOT the repo JSON
250
+
251
+ **Durable rule: never conclude which table view a client's page uses by grepping the frontend repo.
252
+ Query that client's `SurfaceOverrides` in the target environment.** This corrected a wrong
253
+ conclusion mid-session and is the most reusable finding here.
254
+
255
+ Reading `toga25-supply/src` is actively misleading for Elite:
256
+
257
+ - `src/pages/Inventory/viewModel/FIELDS/DEFAULT/inventoryGroupings.json` uses `inventory_items` and
258
+ `units-for-items-for-purchase-orders`.
259
+ - **`inventory_units` appears nowhere in `src`** — which reads as *"`inventory_units` is a dead
260
+ view."* **It is not.**
261
+
262
+ Verified on production `Client_Elite.SurfaceOverrides`:
263
+
264
+ | Element | CONFIG / setting |
265
+ |---|---|
266
+ | **120** | `{"key":"units","levels":[{"fetchSlug":"inventory_units","role":"flat"}]}` |
267
+ | **118** | `unitsByItems` — also points at `inventory_units` |
268
+ | 116 / 117 | `IS_VISIBLE 0` (hidden) |
269
+
270
+ Surface config **overrides** the repo JSON (surface resolution is 100% backend-driven — see
271
+ [surface-frontend](../../../2.0/apps/toga25-supply/features/surface-frontend.md)). So **for Elite
272
+ the live Units view is `inventory_units` (`TableViews` id 21), not
273
+ `units-for-items-for-purchase-orders` (id 17).** Elite's overrides were seeded by
274
+ `2026-08-13a - InventoryGroupingsSurfaceOverrides.sql`.
275
+
276
+ This pairs with the existing team rule that **a committed migration records intent, not deployed
277
+ state** — the frontend repo records the *default*, not this client's state.
278
+
279
+ ## Hiding service lines from Inventory — `2026-09-04b`
280
+
281
+ `2026-09-04b - EliteInventoryHideServiceItems.sql` sets `TableViews.apiWhereClause`:
282
+
283
+ | View | Clause |
284
+ |---|---|
285
+ | `inventory_units` | `(IFNULL(Items.assetTypeId,0):ne:2)` |
286
+ | `inventory_items` | `(Items.partNumber:excludes:SVC-,IFNULL(Items.assetTypeId,0):ne:2)` |
287
+
288
+ `assetTypeId 2` = Elite's `Services` row. Notes for anyone porting this:
289
+
290
+ - **The filter belongs in the database table view, not the frontend** — an explicit scope decision.
291
+ - **`IFNULL(...,0):ne:2` is mandatory, not cosmetic.** A bare `:ne:2` drops every `NULL` row and
292
+ would hide **all unstamped items** — and 110 of Elite's 113 items are unstamped until the cursor
293
+ backfill runs. The shape is copied from Elite's existing `item-fulfillments-for-sales-orders`
294
+ view. Grammar and the NULL-drop rule:
295
+ [apiWhereClause row filtering](../../../2.0/apps/api2/features/tableview-apiwhereclause-row-filtering.md).
296
+ - **The `SVC-` prefix rule is kept on `inventory_items`** because it still catches the three items
297
+ NetSuite mis-types as `InvtPart` (`SVC-TS-ELITE-HDONBOARDING`, `CONFIG/INSTALL`,
298
+ `CONF-RM-INSTALL-SUPP`) — see
299
+ [netsuite-togasupply-sync](./netsuite-togasupply-sync.md).
300
+ - **Both `UPDATE`s are guarded on the current value**, so a re-run is a no-op.
301
+ - `inventory_units` was targeted **because of the `SurfaceOverrides` finding above** — filtering
302
+ view 17 would have had no visible effect for Elite.
303
+
244
304
  ## Gotchas / known issues
245
305
 
246
306
  - **A `Client_*` DB that predates a platform migration fails silently until a user opens the
@@ -252,6 +312,18 @@ Do not revive this without first re-testing finding 2 above.
252
312
  [error-reporting-issue-event](../../../2.0/apps/_underscore/features/error-reporting-issue-event.md).
253
313
 
254
314
  ## Change history
315
+ - 2026-09-04 — **Corrected which table view Elite's Inventory page actually reads, then filtered
316
+ it.** `toga25-supply/src` shows the DEFAULT groupings using `inventory_items` +
317
+ `units-for-items-for-purchase-orders`, and `inventory_units` appears nowhere in `src` — reading as
318
+ a dead view. Production `Client_Elite.SurfaceOverrides` proves otherwise: element **120** CONFIG
319
+ points `units` at `inventory_units` and element **118** (`unitsByItems`) does too, with 116/117
320
+ hidden. **Durable rule recorded: never resolve a client's table view by grepping the frontend
321
+ repo — query that client's `SurfaceOverrides` in the target environment.** Shipped
322
+ `2026-09-04b - EliteInventoryHideServiceItems.sql` to hide service lines via `apiWhereClause` on
323
+ `inventory_units` and `inventory_items`, using the mandatory `IFNULL(Items.assetTypeId,0):ne:2`
324
+ shape (a bare `:ne:2` would hide all 110 unstamped items) and keeping the existing `SVC-` prefix
325
+ rule for the three NetSuite-mis-typed items. Both UPDATEs are guarded so a re-run is a no-op.
326
+ (rgirish)
255
327
  - 2026-08-18 - **The Elite table views shipped as migrations, and two of the planned approaches
256
328
  turned out to be wrong.** Built the `service-requests` view (record 35, 5 OUTER joins, 9 columns)
257
329
  and the all-clients `inventory_units` view (record 31, Items as spine, 15 joins, 13 columns);
@@ -16,8 +16,8 @@ project: Worker
16
16
  client: elite
17
17
  type: profile
18
18
  status: active
19
- updated: 2026-08-26
20
- owners: [snaredla, apeterson, tcox, bala]
19
+ updated: 2026-09-04
20
+ owners: [snaredla, apeterson, tcox, bala, rgirish]
21
21
  files:
22
22
  - worker2/Worker/Elite.php
23
23
  - worker2/Worker/Sync/ServiceRequest.php
@@ -55,6 +55,15 @@ remaining stale views are still an open production go-live risk** - see
55
55
  dbchanges2 **PR #454** (TRUE-80499, snaredla24, 2026-08-05) added the **netsuite** module to
56
56
  `Client_Elite/_modules.txt` — the supply-chain schema layer is arriving.
57
57
 
58
+ **Elite is the only client opted in to NetSuite `itemtype` → `assetType` item stamping
59
+ (2026-09-04),** which is how service lines are hidden from the Inventory page. It needed a
60
+ `Client_Elite` API-role write grant on `Items.assetTypeId` **before** the code deploy, and its
61
+ existing 110 NULL items need a PO cursor rollback to be stamped — see
62
+ [netsuite-togasupply-sync](features/netsuite-togasupply-sync.md) and the
63
+ [shared assetType feature](../../1.0/apps/library/features/netsuite-item-assettype-sync.md). **All
64
+ of Elite's integration flags are now on, invoices included** — invoices had been disabled for
65
+ starving the receipt/fulfillment sections, so that risk is live again.
66
+
58
67
  **Elite is live on the inbound NetSuite → TOGa Supply sync as of 2026-08-12 (TRUE-80499).** A
59
68
  `*/5` worker cron backfilled April 2024 → Aug 2026 in ~90 minutes (257 sales orders, 234 purchase
60
69
  orders, 201 item fulfillments, 153 items, 229 units) — this is what adds **`worker`** (1.0) to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.781",
3
+ "version": "1.0.783",
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",