toga-ai 1.0.724 → 1.0.726

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.
@@ -111,13 +111,27 @@ duplicates** and must be deployed first — see
111
111
  - **`UPDATE IGNORE ...; DELETE ... WHERE fk=dupe` permanently DELETES collision rows** and drops its
112
112
  helper maps, so prod cannot self-reverse. A reversal needs a **pre-merge snapshot** restored as a
113
113
  scratch DB — plan the backup before running any variant of this.
114
- - **Blast radius of the 2026-08-31 `duplicate_items_merge_ALL_CLIENTS.sql`:** it ran on 17 clients
115
- (Masonite, Ncci, Erau, Shrss, Wmchealth, Trividiahealth, Prudential, Miamidade, Browardsheriff,
116
- Nychh, Canon, Quad, Compass, Elite, Growrk, Spglobal, Endeavorhealth). At-risk = two-catalog +
117
- TOGa Commerce clients like Compass; a **per-client assessment is outstanding**.
114
+ - **Blast radius of the 2026-08-31 `duplicate_items_merge_ALL_CLIENTS.sql` (assessment complete
115
+ 2026-09-01):** it ran on 17 clients (Masonite, Ncci, Erau, Shrss, Wmchealth, Trividiahealth,
116
+ Prudential, Miamidade, Browardsheriff, Nychh, Canon, Quad, Compass, Elite, Growrk, Spglobal,
117
+ Endeavorhealth). **Only 2 were damaged — Compass and Quad, both two-catalog TOGa Commerce
118
+ clients.** The other 15 needed no reversal: 13 single-catalog "Agilant"-only clients were a
119
+ legitimate within-catalog dedup, NYCHH mixed Etilize + NetSuite in one catalog (no fold
120
+ possible), and Prudential was cleared (single populated catalog; NULL `assetType` is its normal
121
+ state). Triage per client with `Catalogs` LEFT JOIN `Items` (empty catalogs still show) + the
122
+ pipeline signal (`c_etilizeProductId` = store vs `c_netsuiteInternalItemId` = Agilant); a client
123
+ is damaged only if it has a two-catalog store/procurement design AND catalog-1 was folded into
124
+ catalog-2 AND storefront/transmit depends on catalog-1. See the Compass
125
+ [catalog-fold reversal](../../../clients/compass-usa/workflows/catalog-fold-reversal.md) and
126
+ [Quad catalog-fold reversal](../../../clients/quad/workflows/catalog-fold-reversal.md).
118
127
 
119
128
  ## Change history
120
129
 
130
+ - 2026-09-01 — **Blast-radius assessment complete.** Of the 17 clients the merge ran on, only 2
131
+ were damaged — Compass and Quad (both two-catalog TOGa Commerce). The other 15 needed no
132
+ reversal (13 single-catalog "Agilant"-only = legitimate within-catalog dedup; NYCHH mixed
133
+ Etilize + NetSuite in one catalog; Prudential cleared — single populated catalog, NULL
134
+ assetType is normal). Added the durable triage method and linked the Quad reversal. (jcardinal)
121
135
  - 2026-09-01 — **⚠ Corrected the success claim.** "0 duplicate part numbers" is NOT a clean-run proof
122
136
  for a multi-catalog client. This merge is catalog-blind (groups on `UPPER(partNumber)` only), so for
123
137
  Compass it folded 199 intentional catalog-1 store items into their Agilant canonical, broke MITS
@@ -4,7 +4,7 @@
4
4
  |-----|---------|-------|
5
5
  | [Proposed — git-sourced base+overlay JSON authoring for the Surface layer](architecture/surface-authoring-proposal.md) | A **proposal / handoff recommendation** (not implemented) that the Surface layer's *authoring* model move off hand-authored SQL against the `SurfaceOverrides` E | _underscore/Model/Core/Surface.php, _underscore/Model/Client/SurfaceOverride.php |
6
6
  | [_underscore Framework Architecture](architecture.md) | `_underscore` is the shared PHP backend framework for **all 2.0 applications**. | _underscore/_underscore.php, _underscore/Loader.php, _underscore/Framework.php, _underscore/Model.php, _underscore/Database.php, _underscore/Query.php, _underscore/Route.php, _underscore/Component.php |
7
- | [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, dbchanges2/Core/2026-08-27a - TransferOrderUuidSearchableIdentifier.sql, _underscore/Model/Core/Page.php, _underscore/Model/Core/Surface.php, _underscore/Model/Client/TrackingNumber.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql, dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql, dbchanges2/Client/2026-08-29 - ItemReceiptTransferOrderIdFieldPermission.sql, dbchanges2/Client/2026-08-29b - ItemReceiptItemTransferOrderItemIdFieldPermission.sql |
7
+ | [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, dbchanges2/Core/2026-08-27a - TransferOrderUuidSearchableIdentifier.sql, _underscore/Model/Core/Page.php, _underscore/Model/Core/Surface.php, _underscore/Model/Client/TrackingNumber.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql, dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql, dbchanges2/Client/2026-08-29 - ItemReceiptTransferOrderIdFieldPermission.sql, dbchanges2/Client/2026-08-29b - ItemReceiptItemTransferOrderItemIdFieldPermission.sql, dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql |
8
8
  | [Address Uniqueness Normalization (unit identifier + 5-digit ZIP comparison)](features/address-uniqueness-normalization.md) | When a business rule says *"only one X per physical address"*, comparing address rows field-for-field does **not** work: the same dwelling is spelled many diffe | _underscore/Model/Rate/Entitlement.php |
9
9
  | [Address Validation (carrier waterfall + validateAddress scripted endpoint)](features/address-validation.md) | `_Model_Client_Address::validateAddress` verifies a US address against a **carrier waterfall (USPS → FedEx → UPS)** and returns a single canonical, carrier-norm | _underscore/Model/Client/Address.php, _underscore/Component/Library/Carriers/Usps/Usps.php |
10
10
  | [_ApiRequest — JSON encode/decode & api-logging behavior](features/apirequest-json-content-type.md) | `_ApiRequest` is the 2.0 outbound HTTP client. | _underscore/ApiRequest.php |
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-28
10
- owners: ["jcardinal", "mhammontree", "tcox", "bala"]
9
+ updated: 2026-09-01
10
+ owners: ["jcardinal", "mhammontree", "tcox", "bala", "apeterson"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - dbchanges2/Core/2026-08-27a - TransferOrderUuidSearchableIdentifier.sql
@@ -21,6 +21,7 @@ files:
21
21
  - dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql
22
22
  - dbchanges2/Client/2026-08-29 - ItemReceiptTransferOrderIdFieldPermission.sql
23
23
  - dbchanges2/Client/2026-08-29b - ItemReceiptItemTransferOrderItemIdFieldPermission.sql
24
+ - dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql
24
25
  ---
25
26
 
26
27
  ## Summary
@@ -127,8 +128,46 @@ identical in every environment (the platform's only sanctioned hardcoded ids —
127
128
  next free id differs per environment (a new field took 2483 in production and 2766 in
128
129
  dev-sandbox), so a row you are creating right now may need a per-environment literal.**
129
130
 
130
- **Known-broken existing files (same root cause):** `Client_Rate/2026-07-23a` and
131
- `Client/2026-07-16c`. A clean local run is never proof either worked.
131
+ **Known-broken existing files (same root cause).** A full sweep of `dbchanges2` on **2026-09-01**
132
+ found **12** `Client/` files doing a real cross-cluster read (`FROM`/`JOIN Core.*`). All of them are
133
+ **ACL or surface-grant** migrations, and all fall in a **2026-06-25 → 2026-07-23** window —
134
+ `Core/` is clean, so the leak is one-directional:
135
+
136
+ | File (`dbchanges2/Client/`) |
137
+ |---|
138
+ | `2026-07-22c - TrackingNumberMeasureIdsFieldPermission` (10 `Core.` refs) |
139
+ | `2026-07-16b - TrackingNumberSignatureTypeFieldPermission` |
140
+ | `2026-07-16c - AddressValidateScriptAcl` |
141
+ | `2026-07-23a - EntitlementServiceAddressIdFieldPermission` |
142
+ | `2026-07-17 - ItemsisFulfillable` |
143
+ | `2026-07-10b - ItemFulfillmentReturnAddressIdFieldPermission` |
144
+ | `2026-07-10c - GenerateReturnLabelScriptAcl` |
145
+ | `2026-07-02a - TrackingNumberReprintScriptAcl` |
146
+ | `2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission` |
147
+ | `2026-06-30a - SurfaceMetaGroupAcl` |
148
+ | `2026-06-29c - SurfaceRecordScriptAcl` |
149
+ | `2026-06-25c - SurfaceClientAcl` |
150
+
151
+ (plus `Client_Rate/2026-07-23a`, previously known.) **A clean local run is never proof any of them
152
+ worked** — locally the schema literally named `Core` is the **1.0** database, so the subselect
153
+ resolves against the wrong schema instead of failing.
154
+
155
+ **Their failure mode is silent, and it is worth stating precisely** because it is why nobody caught
156
+ this for two months: `SET @x = (SELECT … FROM Core.X)` yields **NULL**, the `IS NOT NULL` guard then
157
+ suppresses the `INSERT`, and **the file reports success having granted nothing**. No error, no
158
+ warning, zero rows.
159
+
160
+ **Confirmed live instance:** `Client_Adyen` has `2026-07-22c`'s mirror sibling grant but **none of
161
+ that file's three measure-id fields** — exactly the shape this bug produces.
162
+
163
+ Verified on production 2026-09-01: both `reader1.client.database.togahub.com` and
164
+ `writer.client.database.togahub.com` host **only `Client_*` databases — there is no `Core` there
165
+ at all**, so a `Core.X` reference from a `Client/` file cannot resolve under any circumstances.
166
+
167
+ **New occurrences are now blocked** by the `dbchanges2-cluster-isolation` PreToolUse hook
168
+ (`.claude/hooks/toga/dbchanges2-cluster-isolation.js`), which inspects every `Write`/`Edit`.
169
+ The 12 above pre-date it and are a **remediation backlog**, not a closed issue — each needs its
170
+ `Core` subselect swapped for a hardcoded `Core.RecordFields` id literal and then re-running.
132
171
 
133
172
  ### Standard field vs. custom (`c_`) field — TWO different permission tables
134
173
 
@@ -191,6 +230,33 @@ first (see
191
230
  granting access therefore goes in `dbchanges2/Client/` (or a specific `Client_<Name>/`) so it
192
231
  runs against every client DB.
193
232
 
233
+ ### ⚠ Read `aclDatabase` FIRST — querying the wrong database gives a confident, meaningless answer
234
+
235
+ This is the step that is easiest to skip and the most expensive to skip, because **both databases
236
+ have an `AclFieldPermissions` table**. Query the wrong one and you get rows back, draw a conclusion,
237
+ and never learn it was about a different record. Verified the hard way 2026-09-01: `Core` was
238
+ checked for `purchase-order-items` (`Records.id = 18`), which is **`aclDatabase = 'CLIENT'`** — so
239
+ Core's rows are **never consulted for that record** and the whole investigation went down a wrong
240
+ path on the strength of them.
241
+
242
+ **Recipe — "a field returns 0/null but the data is there":**
243
+
244
+ 1. **Establish the ACL database before anything else:**
245
+ ```sql
246
+ SELECT id, route, aclDatabase FROM Core.Records WHERE route = '<route>';
247
+ ```
248
+ 2. **Query `AclFieldPermissions` in THAT database** — `Core` if `CORE`, `Client_<Tenant>` if
249
+ `CLIENT`. Rows in the other one are noise.
250
+ 3. **Compare a field that WORKS against the broken one, side by side.** A role row present on one
251
+ and absent on the other is conclusive; a raw list of grants for the broken field alone is not,
252
+ because you have no baseline for what "granted" looks like on that record.
253
+
254
+ > **⚠ Before spending any time on steps 1–3, check whether ACL can even be the cause.** If the
255
+ > field is **`_`-prefixed and requested through an explicit `fields=` list**, `AclFieldPermissions`
256
+ > is **bypassed entirely** and no grant you add or remove will change the result — see
257
+ > [V2 REST query contract → ACL behavior](../../api2/features/v2-rest-query-contract.md). Confirm
258
+ > the request shape first; it eliminates the entire ACL chain in one step.
259
+
194
260
  **Role ids differ per client DB**, so never hardcode a `roleId` in a `CLIENT` ACL migration —
195
261
  resolve it with a subselect, e.g. the Base role:
196
262
 
@@ -423,6 +489,46 @@ and **succeeds with 0 inserted and no error**. Always confirm the sibling has ro
423
489
  `shippingMethodId` (338), `measures.slug` (723), `containsBattery` (962), `shipToAddressId` (991).
424
490
  **Do NOT mirror `needsReturnLabel`** — its grants are absent in some clients.
425
491
 
492
+ ### ⚠ But when you know the TARGET role, grant it EXPLICITLY — mirroring is the riskier option
493
+
494
+ The mirror practice above answers *"which roles read this kind of field?"* when you genuinely do not
495
+ know. **When you already know the role you need, name it — do not mirror.** Mirroring looks tidier
496
+ and reads as the house style (`Client/2026-07-22c` is the precedent), but it copies whatever the
497
+ sibling happens to have in each tenant, and that set is **not uniform**. Two live failures from one
498
+ 2026-09-01 survey of the `PurchaseOrderItems._qty*` fields:
499
+
500
+ - **Mirroring over-grants.** `Client_Compass` grants `quantity` to **role 4 (Public)**. A migration
501
+ mirroring `quantity` would have handed the **Public** role read access to eight derived inventory
502
+ quantities across that tenant — a real widening, applied silently, in a file whose stated purpose
503
+ was to grant the API role.
504
+ - **Mirroring under-grants.** `Client_Imhouston` has **no `quantity` grant at all**, so the same
505
+ mirror inserts **zero rows** there and the field stays broken — the silent-no-op failure already
506
+ described above.
507
+
508
+ **Rule: resolve the role BY NAME (`(SELECT id FROM Roles WHERE name = 'API')`) and grant it
509
+ explicitly.** You keep the per-tenant role-id independence that mirroring was bought for, without
510
+ inheriting a per-tenant role *set* you never inspected. Reserve mirroring for the case where the
511
+ reader role is genuinely unknown — and when you do mirror, dry-run it per tenant and read the role
512
+ ids it would actually insert.
513
+
514
+ ### Fleet reality check: a template mis-seed means "most tenants" is not "correct"
515
+
516
+ The same survey (36 production client databases, 2026-09-01) on the eight
517
+ `PurchaseOrderItems._qty*` fields:
518
+
519
+ | Grant state | Tenants |
520
+ |---|---|
521
+ | all eight granted to **role 1 (Base)**, **none to role 3 (API)** | **31** |
522
+ | **no `_qty*` grants at all** | 4 — Compass, Imhouston, Mcgrawhill, Tdsynnex |
523
+ | roles 1 **and** 3 (correct) | 1 — Prudential |
524
+ | role 3 (correct for API use) | 1 — Northwell |
525
+
526
+ **A 31-of-36 majority was the WRONG state**, not the baseline — this is a template-level mis-seed
527
+ propagated by the blank-client DDL, so every tenant provisioned from it inherits the same gap.
528
+ Practical consequence: **never infer the intended grant from what most tenants have.** Derive it
529
+ from which role actually reads the field, then check whether the majority agrees; when it does not,
530
+ suspect the template rather than the two outliers.
531
+
426
532
  Reconfirmed 2026-08-03 (TRUE-80494): `measures.measureType` had **zero** grant rows in dev-sandbox
427
533
  `Client_Growrk`, so it was omitted from `GET /v2/measures`; the frontend's strict
428
534
  `m.measureType === "LENGTH"` split then produced empty arrays and **both unit dropdowns rendered
@@ -492,6 +598,26 @@ hardcoded `Core.RecordFields` id literals instead of a subselect.
492
598
  and every repo is on the **same branch** so the generated model matches the DB.
493
599
 
494
600
  ## Change history
601
+ - **2026-09-01** — Four additions from a `PurchaseOrderItems._qty*` grant investigation.
602
+ (1) **Read `Core.Records.aclDatabase` FIRST** — both databases carry an `AclFieldPermissions`
603
+ table, so checking Core for a `CLIENT`-acl record (here `purchase-order-items`, record 18) returns
604
+ rows and a confident wrong answer; added the compare-a-working-field diagnostic recipe, gated on
605
+ first confirming ACL can even apply (an `_`-prefixed field in an explicit `fields=` list **bypasses
606
+ `AclFieldPermissions` entirely** — new section in
607
+ [v2-rest-query-contract](../../api2/features/v2-rest-query-contract.md)).
608
+ (2) **Grant a KNOWN target role explicitly rather than mirroring a sibling** — mirroring `quantity`
609
+ would have granted **role 4 (Public)** eight derived inventory quantities on `Client_Compass` and
610
+ inserted **nothing** on `Client_Imhouston` (no `quantity` grant there); resolve by role *name*
611
+ instead. This qualifies, and partly reverses, the house mirror precedent `Client/2026-07-22c`.
612
+ (3) **A 31-of-36 majority can be the wrong state** — 31 prod tenants grant the `_qty*` fields to
613
+ role 1 and none to role 3; only Prudential and Northwell are correct. Template-level mis-seed, so
614
+ never infer the intended grant from the majority.
615
+ (4) **Expanded the known-broken cross-cluster file list from 2 to 12** (full `dbchanges2` sweep;
616
+ all `Client/` ACL/surface grants in a 2026-06-25 → 2026-07-23 window, `Core/` clean), stated the
617
+ silent failure mechanism (`SET @x = (SELECT … FROM Core.X)` → NULL → guard suppresses the INSERT →
618
+ reports success), confirmed `Client_Adyen` as a live instance, verified on prod that the client
619
+ cluster hosts no `Core` at all, and noted the new `dbchanges2-cluster-isolation` PreToolUse hook
620
+ blocks new ones while these 12 remain a remediation backlog. (apeterson)
495
621
  - **2026-08-29** — Two more worked examples of the **standard-field write grant via hardcoded
496
622
  `Core.RecordFields` id literal** (the cross-cluster caveat — a `Client_*`→`Core` JOIN is refused on the
497
623
  prod client cluster), each cloning a working sibling: `ItemReceipts.transferOrderId` (Core RF **2153**,
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-30
9
+ updated: 2026-09-01
10
10
  owners: [tcox, bala, apeterson, jcardinal]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -259,6 +259,46 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
259
259
  list, **and the permitted data still populated**.
260
260
  - **Recommendation: always send an explicit `fields=` list.** Silent omission is how a
261
261
  consumer ends up "working" against data it never actually received.
262
+ - **⚠ That recommendation holds for PLAIN fields only — an `_`-prefixed field named in
263
+ `fields=` is never ACL-checked at all.** See the next section.
264
+
265
+ ### ⚠ An explicitly-requested `_`-prefixed field BYPASSES `AclFieldPermissions` entirely
266
+
267
+ This is the exception to everything above, and it inverts the usual debugging instinct: for an
268
+ underscore-prefixed field, **adding an `AclFieldPermissions` grant changes nothing** about an
269
+ explicit `fields=` request. Verified 2026-09-01 on `purchase-order-items._qty*`.
270
+
271
+ `processRoutePairs()` classifies each entry of an explicit `fields=` list in a fixed order, and the
272
+ ACL branch is **not first**:
273
+
274
+ | `V2.php` | branch | ACL checked? |
275
+ |---|---|---|
276
+ | **:3338** | `$modelFieldConfig[$f]['type'] == _Model::FIELD_SQL` → **calculated field** | **no** |
277
+ | **:3353** | field starts with `_` but is not `FIELD_SQL` → reclassified as an **aggregate function** (`'function' => substr($field, 1)`) | **no** |
278
+ | **:3375–3381** | plain field with no grant → *"not permitted access, unauthorized"* → **`EZ-2`** | yes |
279
+
280
+ So `fields=_qtyAvailable` resolves at **:3338** and the request never reaches the ACL branch. The
281
+ ACL gate for calculated fields lives at **:7216** in `getFullModelData()` — which is the
282
+ **no-explicit-`fields=`** path only.
283
+
284
+ **The two practical consequences:**
285
+
286
+ 1. **Granting `AclFieldPermissions` does not change the result of an explicit `fields=` request for
287
+ a calculated field.** If a `_qty*` field returns `0` with the grant added and with it removed,
288
+ the ACL was never the variable — stop testing grants and go look at the SQL the field emits
289
+ (and at *which model class* is emitting it — see the NYCHH case linked below).
290
+ The grant is still worth having: it is load-bearing on the `getFullModelData()` path.
291
+ 2. **Plain fields fail LOUDLY, calculated fields fail SILENTLY.** An ungranted plain field is a
292
+ `EZ-2` 403 you cannot miss. An ungranted `_`-prefixed field is quietly turned into an aggregate
293
+ function, and — because these fields typically carry
294
+ `FIELDOPT_SQL_TYPE => FIELD_DECIMAL` — the serializer still emits the key and it renders as
295
+ **`0`**. It reads as a broken calculation, not as a permission problem, which is exactly the
296
+ wrong place to start looking. (The `FIELD_DECIMAL` cast is documented on
297
+ [FIELD_SQL calculated fields](../../_underscore/features/calculated-sql-fields.md).)
298
+
299
+ **Corollary for the `fields=`-forces-`EZ-2` diagnostic above:** re-requesting with an explicit
300
+ `fields=` list to smoke out a denied field **only works on plain columns**. It will never produce an
301
+ `EZ-2` for a `_`-prefixed field, so a silent `0` is not evidence that the grant exists.
262
302
  - **`EZ-1` vs `EZ-2` for a consumer:** `EZ-1` = record-level block (`data.<route>` is `null`) —
263
303
  hard blocked, nothing usable. `EZ-2` = field-level — on a **LIST** it is
264
304
  *degraded-but-usable*; on a **by-uuid READ** it is **fatal**.
@@ -296,7 +336,14 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
296
336
  - **A 500 (`EO-1`) on a filter ⇒ suspect a `where` reference to an unjoined table.**
297
337
  - **`gte`/`lte` silently are not operators** — use `ge`/`le`.
298
338
  - **Do not trust a 200 with missing fields.** Without an explicit `fields=` list, ACL removal is
299
- invisible; re-request with `fields=` to force `EZ-2` and see the denied list.
339
+ invisible; re-request with `fields=` to force `EZ-2` and see the denied list. **⚠ Plain columns
340
+ only** — an `_`-prefixed field named in `fields=` is never ACL-checked and returns `0`, not
341
+ `EZ-2` (see the ACL section).
342
+ - **⚠ A calculated field returning `0` is almost never an ACL problem.** Eliminate ACL in one
343
+ step (it is bypassed on the explicit-`fields=` path), then check **which model class** supplies
344
+ the expression — a `_Model_<Client>_X` override on the *deployed* `_underscore` branch can
345
+ replace the base SQL with something that legitimately evaluates to `0`. Worked example:
346
+ [NYCHH transfer-order inventory quantities](../../../clients/nychh/features/transfer-order-inventory-quantities.md).
300
347
  - **⚠ A PUT response ECHO can omit a field that DID persist — verify against the DB, not the echo.**
301
348
  A `PUT /v2/inventory-adjustments/{uuid}` with `{"itemFulfillment":{"uuid":…}}` returned **200 with
302
349
  an empty `messages[]`** and an echo that did **not** contain `itemFulfillment`, yet the FK column
@@ -306,6 +353,16 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
306
353
  [V2 request logging](request-logging.md).
307
354
 
308
355
  ## Change history
356
+ - 2026-09-01 — **Recorded the biggest exception to this doc's own "always send an explicit `fields=`
357
+ list" advice: an `_`-prefixed field named in `fields=` is NEVER ACL-checked.** `V2.php:3338`
358
+ classifies a `FIELD_SQL` field as calculated *before* the ACL branch at `:3353`, and `:3353` then
359
+ reclassifies any other `_`-prefixed field as an **aggregate function** rather than denying it —
360
+ only plain fields reach the `EZ-2` branch at `:3375-3381`. The ACL gate for calculated fields is
361
+ at `:7216`, on the **no-explicit-`fields=`** path. Two consequences: adding an
362
+ `AclFieldPermissions` grant cannot change the result of an explicit `fields=_qtyX` request, and
363
+ ungranted calculated fields fail **silently as `0`** (the `FIELDOPT_SQL_TYPE => FIELD_DECIMAL`
364
+ cast still emits the key) while plain fields fail loudly. Found while a `_qty*` field returned 0
365
+ and a grant migration was written against the wrong theory. (apeterson)
309
366
  - 2026-08-29 — Recorded two response-shape facts from the NYCHH inventory-adjustment REST work: **a
310
367
  non-null FK object one hop past the max `depth` is silently omitted** (a null sibling FK still
311
368
  renders, so it reads as "null" not "missing") — `GET /item-fulfillment-items` at `depth:2` dropped
@@ -3,7 +3,7 @@
3
3
  | Doc | Summary | Files |
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
- | [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 |
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
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/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 |
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 |
@@ -6,15 +6,17 @@ project: Database Changes
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-31
9
+ updated: 2026-09-01
10
10
  owners: [bala, kyalamarthi, apeterson]
11
11
  files:
12
12
  - dbchanges2/Client_Compass/2026-08-26a - CompassCreativeStudioPersona.sql
13
13
  - dbchanges2/Core/2026-08-27a - Insert - Netsuite Location SyncAll CronJob.sql
14
+ - dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql
14
15
  related:
15
16
  - ../architecture.md
16
17
  - ../../worker2/features/netsuite-salesorder-open-orders-sync.md
17
18
  - ../../_underscore/features/acl-permission-chain.md
19
+ - ../../_underscore/features/calculated-sql-fields.md
18
20
  - ../../_underscore/features/tracking-number-bridges.md
19
21
  - ../../../../clients/compass-usa/workflows/granting-persona-bundle-access.md
20
22
  - ../../../../clients/compass-usa/workflows/granting-navigation-access.md
@@ -49,6 +51,36 @@ and `SUBSTRING('89ab', …)` picks the variant nibble. Verified working on prod
49
51
  that is *not* v4-shaped (older migrations concatenated `RANDOM_BYTES` without the version/variant
50
52
  nibbles). They are fine and must not be "fixed" — but do not use them as the template for new SQL.
51
53
 
54
+ ### ⚠ In a FAN-OUT grant migration, use HARDCODED uuid literals — generated uuids make rollback unsafe
55
+
56
+ The generator above is right for a normal additive insert. It is **wrong for a `Client/` fan-out
57
+ that grants a permission across every tenant**, because you cannot write a safe rollback for rows
58
+ whose uuids you do not know.
59
+
60
+ The tempting rollback is a natural key:
61
+
62
+ ```sql
63
+ -- DANGEROUS in a fan-out: deletes grants this file never created
64
+ DELETE FROM AclFieldPermissions WHERE recordFieldId IN (…) AND roleId = (SELECT id FROM Roles WHERE name = 'API');
65
+ ```
66
+
67
+ That also removes **pre-existing** grants held by the tenants that were already correct. Concrete
68
+ case (2026-09-01, the `PurchaseOrderItems._qty*` role-3 grant): Prudential and Northwell already had
69
+ these rows, so a key-based rollback would have **revoked working production permissions** on two
70
+ tenants as collateral for undoing 34 others.
71
+
72
+ **Instead, hardcode one v4 uuid literal per row in the file.** The rollback is then exact:
73
+
74
+ ```sql
75
+ DELETE FROM AclFieldPermissions WHERE uuid IN ('<literal-1>', …, '<literal-8>');
76
+ ```
77
+
78
+ It can only ever match rows this file inserted. **Reusing the same literal across tenants is valid**
79
+ — `uuid` is unique *per table per database*, and each tenant is a separate database, so one literal
80
+ per (row × file) is enough for the whole fan-out. Keep the `NOT EXISTS` guard on the real unique key
81
+ (here `(recordFieldId, roleId)`) so re-running is still a no-op on tenants that already have the
82
+ grant; the literal governs only deletion.
83
+
52
84
  ### ⚠ `DISTINCT` cannot dedupe a row that carries a random uuid
53
85
 
54
86
  This is the silent one. In an `INSERT … SELECT`, putting the uuid expression inside a
@@ -235,8 +267,29 @@ concluding a migration is production-safe.
235
267
  file: separate production clusters mean a `Core.` reference is unrunnable in prod even though it
236
268
  works locally. Hardcode `Core.Records` / `Core.RecordFields` ids as literals — see
237
269
  [dbchanges2 architecture](../architecture.md).
270
+ - **⚠ Read the sibling migration's `INSERT` HEADER — never infer the column list from its `VALUES`
271
+ row.** Copying a neighbouring file is the normal way to start a migration, and matching the
272
+ *shape* of its values tuple (right arity, plausible-looking types) produces a file that looks
273
+ correct and fails on execution. Verified 2026-09-01: `Core.Messages` is
274
+ **`(id, uuid, dtCreated, messageKey, name, defaultValue)`** — a guess of `slug` / `message`
275
+ (inferred from a sibling's values row) died on `Unknown column 'slug'`. When in doubt,
276
+ `SHOW COLUMNS FROM <Table>` costs one round-trip.
277
+ - **`Core.RecordFields` has NO `sql` column.** A calculated field's SQL is **not** stored in
278
+ metadata — it lives in the `_underscore` model as a `FIELD_SQL` static method. Do not go looking
279
+ for a migration to change a calculated field's expression; there is none. See
280
+ [FIELD_SQL calculated fields](../../_underscore/features/calculated-sql-fields.md).
238
281
 
239
282
  ## Change history
283
+ - 2026-09-01 — Added the **fan-out rollback rule**: a `Client/` grant migration that fans out across
284
+ every tenant must use **hardcoded v4 uuid literals**, not the generator, so its rollback
285
+ (`DELETE … WHERE uuid IN (…)`) can only touch rows that file created. A natural-key rollback on
286
+ `(recordFieldId, roleId)` would have revoked **pre-existing production grants** on Prudential and
287
+ Northwell while undoing the `PurchaseOrderItems._qty*` role-3 fan-out. Reusing one literal per row
288
+ across tenants is valid (uuid is unique per table per database). Also recorded two schema gotchas:
289
+ **read a sibling's `INSERT` header, never infer columns from its `VALUES` row** (`Core.Messages` is
290
+ `(id, uuid, dtCreated, messageKey, name, defaultValue)` — a guessed `slug` failed with *Unknown
291
+ column*), and **`Core.RecordFields` has no `sql` column** — calculated-field SQL lives in the
292
+ model, not in metadata. (apeterson)
240
293
  - 2026-08-28 — Added **conditional (re-runnable) ALTERs**: MySQL 8 has no
241
294
  `ADD COLUMN IF NOT EXISTS`, so a `Client/` all-tenant file must gate each `ALTER` on
242
295
  `information_schema` (with `TABLE_SCHEMA = DATABASE()` for cluster safety) and run it through
@@ -6,7 +6,7 @@ project: Database Changes
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-08-31
9
+ updated: 2026-09-01
10
10
  owners: [apeterson]
11
11
  files:
12
12
  - api2/Config/production.ini
@@ -85,11 +85,24 @@ Delete the file when you are done.
85
85
  `Table 'Client_Nychh.Messages' doesn't exist`, which names a missing table rather than the real
86
86
  mistake. If you see "table doesn't exist" for a table you know is in Core, check which schema you
87
87
  piped the file into before you touch the SQL.
88
+ - 🚨 **A `Client/` fan-out hits EVERY `Client_*` schema on the cluster — including dated BACKUP
89
+ databases.** The production client cluster carries copies that are not tenants. Found
90
+ 2026-09-01: **`Client_Compass_20260831_120000`**, a dated backup of `Client_Compass`, sitting
91
+ alongside the live schemas. Any "run this against every `Client_*` database" loop picks it up,
92
+ which at best wastes a run and at worst mutates a snapshot someone is holding for a restore.
93
+ **Before a fan-out, list the target schemas and eyeball them** —
94
+ `SHOW DATABASES LIKE 'Client\_%'` — and exclude anything matching a `_YYYYMMDD_HHMMSS` suffix or
95
+ otherwise absent from `Core.Clients`. Do not assume the tenant list and the schema list agree.
88
96
  - **The repo is evidence of intent, never of what an environment contains.** Confirming a file ran
89
97
  is its own procedure — see
90
98
  [Verifying whether a migration actually ran](./verifying-a-migration-ran.md).
91
99
 
92
100
  ## Change history
101
+ - 2026-09-01 — Added the **backup-database hazard for `Client/` fan-outs**: the production client
102
+ cluster hosts dated snapshots alongside live tenants (`Client_Compass_20260831_120000`, found
103
+ during a 36-database ACL survey), and a naive "every `Client_*` schema" loop will run against
104
+ them. List and filter the target schemas (`SHOW DATABASES LIKE 'Client\_%'`, drop
105
+ `_YYYYMMDD_HHMMSS` suffixes / anything not in `Core.Clients`) before fanning out. (apeterson)
93
106
  - 2026-08-31 — Initial. Captured after a dump taken from `127.0.0.1` was restored onto sandbox-client
94
107
  `Client_Nychh` believing it was production (the tell: sandbox matched local exactly). Records the
95
108
  verify-by-differing-row-count check, the `--single-transaction` snapshot-drift caveat, the
@@ -6,7 +6,7 @@ project: TOGa Blox
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-27
9
+ updated: 2026-09-01
10
10
  owners: [apeterson, tcox]
11
11
  files:
12
12
  - toga-blox/src/reactQuery/queryHelpers.ts
@@ -118,6 +118,25 @@ Filterable`, `hyperlinkField`, `imageUrlField`, `sticky`, `precision`, …), `Da
118
118
  paren pair so the group re-wrap doesn't double-wrap. Any raw pre-formatted where clause was
119
119
  100% broken before this. If you add a new raw-string clause path, route it through this string
120
120
  branch — do **not** feed a raw clause into an object-expecting serializer.
121
+ - **⚠ Every `where` VALUE must be a STRING — a number throws inside `buildUrl` and NO request is
122
+ ever sent.** `urlEncode(input: string)` calls `input.replace()`, so a numeric value raises
123
+ `input.replace is not a function` **synchronously** inside `assembleOptions` → `buildUrl` →
124
+ `queryFn`. React Query reports it as a query error, and because the throw happens *before* the
125
+ fetch, **DevTools shows no network activity at all** — the symptom is an empty list/dropdown with
126
+ a completely clean network tab, which reads as a backend or ACL problem and is neither.
127
+
128
+ ```ts
129
+ // WRONG — throws in buildUrl, no HTTP request
130
+ where: { "Locations.isActive": { "=": 1 } }
131
+
132
+ // CORRECT
133
+ where: { "Locations.isActive": { "=": "1" } }
134
+ ```
135
+
136
+ TypeScript does not catch it today. This is the **second** shape-assumption failure in
137
+ `reactQuery/queryHelpers.ts` (the raw-string `apiWhereClause` bug above is the first): the
138
+ serializer trusts its input's type and fails loudly only at the very end of the chain. Verified
139
+ 2026-09-01 in `toga25-supply` (`useTargetLocationOptions.ts`, `useTransferSourceLocation.ts`).
121
140
  - **⚠ AUTHORING DEFECT — `handleClientAuthentication(uuid)` collides by name with app-local SSO
122
141
  helpers and is not interchangeable with them.** blox's version `POST`s
123
142
  `/auth/encrypted-user-uuid` with an **empty payload** (`{client:"",user:""}`), writes
@@ -152,6 +171,12 @@ Filterable`, `hyperlinkField`, `imageUrlField`, `sticky`, `precision`, …), `Da
152
171
  thread, not a settled finding.
153
172
 
154
173
  ## Change history
174
+ - 2026-09-01 — Recorded that **`where` values must be STRINGS**: `urlEncode` calls
175
+ `input.replace()`, so a numeric value throws `input.replace is not a function` synchronously in
176
+ `buildUrl`/`queryFn` and **no HTTP request is made at all** — an empty dropdown with a clean
177
+ network tab, easily misread as a backend/ACL fault. Second shape-assumption failure in
178
+ `reactQuery/queryHelpers.ts` after the 2026-08-17 raw-string clause bug; TypeScript does not catch
179
+ it. Hit in `toga25-supply`'s Create Transfer Order modal. (apeterson)
155
180
  - 2026-08-27 — Recorded three blox **authoring defects** surfaced while debugging toga25-supply SSO:
156
181
  (1) `handleClientAuthentication` collides by name with app-local SSO helpers and returns a
157
182
  `/users` payload with no `singleSignOnServiceUrl`; (2) `fetchPublicToken` persists the public
@@ -14,7 +14,7 @@
14
14
  | [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 |
15
15
  | [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/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/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 |
16
16
  | [Talos Integration (AppLayout host + adapter wiring)](features/talos-integration.md) | toga25-supply is the first host of the shared blox [Talos assistant](../../toga-blox/features/talos-assistant.md). | toga25-supply/src/layout/AppLayout/AppLayout.tsx, toga25-supply/src/components/Header/Header.tsx, toga25-supply/src/components/Header/Header.module.css, toga25-supply/src/index.css, toga25-supply/src/assets/talos-owl.png |
17
- | [Transfer Orders page (TableView → Core surfaces → React page + record modal)](features/transfer-orders-page.md) | The Transfer Orders screen — list + read-only record modal — built end to end on 2026-08-28 from a Claude Design prototype. | toga25-supply/src/pages/TransferOrders/TransferOrders.tsx, toga25-supply/src/pages/TransferOrders/hooks/useTransferOrdersTableState.ts, toga25-supply/src/pages/TransferOrders/viewModel/useTransferOrdersPageViewModel.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrdersTableLayout/TransferOrdersTableLayout.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/TransferOrderRecordModalLayout.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/hooks/useTransferOrderRecord.ts, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/viewModel/useTransferOrderRecordModalLayoutModel.ts, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/view/TransferOrderRecordView.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/CreateTransferOrderModal.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/index.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTransferableItemRows.ts, toga25-supply/src/surface/useColumnVisibilityModalConfig.ts, toga25-supply/src/routes.tsx, dbchanges2/Core/2026-08-28a - TransferOrderSurfaceSeed.sql, dbchanges2/Core/2026-08-28b - TransferOrderListActionsSurfaceSeed.sql, dbchanges2/Client/2026-08-28a - TransferOrderStageThemeTokens.sql, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql, dbchanges2/Client_Nychh/2026-08-28a - TransferOrdersTableView.sql, dbchanges2/Client_Nychh/2026-08-28b - TransferOrdersNavigationEnable.sql, dbchanges2/Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql |
17
+ | [Transfer Orders page (TableView → Core surfaces → React page + record modal)](features/transfer-orders-page.md) | The Transfer Orders screen — list + read-only record modal — built end to end on 2026-08-28 from a Claude Design prototype. | toga25-supply/src/pages/TransferOrders/TransferOrders.tsx, toga25-supply/src/pages/TransferOrders/hooks/useTransferOrdersTableState.ts, toga25-supply/src/pages/TransferOrders/viewModel/useTransferOrdersPageViewModel.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrdersTableLayout/TransferOrdersTableLayout.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/TransferOrderRecordModalLayout.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/hooks/useTransferOrderRecord.ts, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/viewModel/useTransferOrderRecordModalLayoutModel.ts, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/view/TransferOrderRecordView.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/CreateTransferOrderModal.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/index.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/usePurchaseOrderItemRows.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTargetLocationOptions.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTransferSourceLocation.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useLocationContacts.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useCreateLocationContact.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/AddContactModal.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/TransferNotesField.tsx, dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql, toga25-supply/src/surface/useColumnVisibilityModalConfig.ts, toga25-supply/src/routes.tsx, dbchanges2/Core/2026-08-28a - TransferOrderSurfaceSeed.sql, dbchanges2/Core/2026-08-28b - TransferOrderListActionsSurfaceSeed.sql, dbchanges2/Client/2026-08-28a - TransferOrderStageThemeTokens.sql, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql, dbchanges2/Client_Nychh/2026-08-28a - TransferOrdersTableView.sql, dbchanges2/Client_Nychh/2026-08-28b - TransferOrdersNavigationEnable.sql, dbchanges2/Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql |
18
18
  | [AWS Amplify Multi-Environment Deployment](workflows/amplify-deployment.md) | How `toga25-supply` deploys to **all** of its environments on AWS Amplify from a **single shared `amplify.yml`**. | toga25-supply/amplify.yml, toga25-supply/src/api/api.ts, toga25-supply/src/hooks/useAuthenticationFlow.ts, toga25-supply/vite.config.ts, toga25-supply/package.json, toga25-supply/.env.development |
19
19
  | [Cypress Testing Harness (component + e2e)](workflows/cypress-testing.md) | The Cypress test harness for the `toga25-supply` frontend, bootstrapped from scratch (`cypress` was already a dependency but there was no config, no `cypress/` | toga25-supply/cypress.config.ts, toga25-supply/cypress/tsconfig.json, toga25-supply/cypress/support/component.tsx, toga25-supply/cypress/support/component-index.html, toga25-supply/cypress/support/e2e.ts, toga25-supply/cypress/support/commands.ts, toga25-supply/cypress/support/fixtures.ts, toga25-supply/cypress/support/mocks/useApprovalModalViewModel.ts, toga25-supply/cypress/component/SalesOrderApprovalModalsLayout.cy.tsx, toga25-supply/cypress/component/RecordApprovalModalLayout.cy.tsx, toga25-supply/cypress/component/EnterPoNumberModal.cy.tsx, toga25-supply/cypress/e2e/salesOrderApproval.cy.ts |
20
20
  | [Porting a page (or query) from toga2-supply to toga25-supply](workflows/porting-a-page-from-toga2-supply.md) | `toga25-supply` re-implements pages that already exist in `toga2-supply`. | toga25-supply/src/pages/SalesOrders/viewModel/FIELDS/PRUDENTIAL/exportApiFields.json, toga25-supply/src/pages/SalesOrders/api/prudentialExportApi.ts, toga2-supply/src/pages/Orders/api/OrdersApi.ts, toga2-supply/src/components/ui/Tables/hooks/useExportableData.tsx |
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-06-23
9
+ updated: 2026-09-01
10
10
  owners: [apeterson]
11
11
  files:
12
12
  - toga25-supply/src/main.tsx
@@ -111,5 +111,12 @@ its primary consumer and resolves it via a symlinked `node_modules/@agilant/toga
111
111
  each other's cached data (e.g. record modal vs. approval modal — `scope="record-modal"` vs
112
112
  `scope="approval-modal"`). Including `additionalData` in the query key is mandatory for
113
113
  WHERE-filtered modal tables, or React Query serves a stale unfiltered first render.
114
+ - **⚠ Data hooks must use `staleTime: 0` — the query cache is PERSISTED.** `App.tsx` wraps the app
115
+ in `PersistQueryClientProvider` with a `createSyncStoragePersister` on the `localStorage` key
116
+ `supply-chain-query-cache`, so cache entries survive reloads *and* sessions. The `QueryClient`
117
+ default is `staleTime: 0` for exactly this reason. A hook that sets its own non-zero `staleTime`
118
+ can serve a persisted entry from a **previous session** and never fetch — the symptom is an empty
119
+ list/dropdown with **nothing in the network tab**, which reads as a backend fault and is not one.
120
+ (`staleTime: Infinity` on the **surface-meta** hooks is a deliberate, separate exception.)
114
121
  - **Typecheck with `npx tsc --noEmit` / `npx tsc -b`** after changes; adding a required key to
115
122
  `ClientFields` surfaces every bundle missing it.