toga-ai 1.0.725 → 1.0.727

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.
@@ -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.
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
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: [apeterson]
11
11
  files:
12
12
  - toga25-supply/src/pages/TransferOrders/TransferOrders.tsx
@@ -19,7 +19,14 @@ files:
19
19
  - toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/view/TransferOrderRecordView.tsx
20
20
  - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/CreateTransferOrderModal.tsx
21
21
  - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/index.ts
22
- - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTransferableItemRows.ts
22
+ - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/usePurchaseOrderItemRows.ts
23
+ - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTargetLocationOptions.ts
24
+ - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTransferSourceLocation.ts
25
+ - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useLocationContacts.ts
26
+ - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useCreateLocationContact.ts
27
+ - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/AddContactModal.tsx
28
+ - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/TransferNotesField.tsx
29
+ - dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql
23
30
  - toga25-supply/src/surface/useColumnVisibilityModalConfig.ts
24
31
  - toga25-supply/src/routes.tsx
25
32
  - dbchanges2/Core/2026-08-28a - TransferOrderSurfaceSeed.sql
@@ -39,6 +46,7 @@ related:
39
46
  - ../../_underscore/features/tableview-joins.md
40
47
  - ../../api2/features/v2-rest-query-contract.md
41
48
  - ../../api2/features/tableview-field-metadata.md
49
+ - ../../toga-blox/features/api-client.md
42
50
  - ../../../../clients/nychh/profile.md
43
51
  - ../../../../clients/nychh/features/transfer-order-inventory-quantities.md
44
52
  ---
@@ -53,9 +61,12 @@ reserved-id seed → per-tenant TableView → per-tenant nav opt-in → surface-
53
61
  **It was fully greenfield.** An audit of production, beta, sandbox-client **and all 36 client
54
62
  databases** on 2026-08-28 found **no transfer-order TableView anywhere**. Nothing was extended.
55
63
 
56
- A **Create Transfer Order** modal was started the same day: the UI and its interaction rules are
57
- built, the data layer is **disabled behind a flag** pending three unanswered data-model questions
58
- (see below).
64
+ A **Create Transfer Order** modal was started the same day and **reworked on 2026-09-01** against
65
+ the live Claude Design export. Its data layer is wired (two of the three blocking data-model
66
+ *questions* dissolved — availability comes from per-PO-line `_qty*`, and Locations are flat) and the
67
+ order is scoped by a **purchase-order lock**, not by source location. 🚨 **But the picker still
68
+ shows nothing for NYCHH**: every PO line reports availability 0 because of an upstream data gap,
69
+ which is an open integration decision — see the blocker below.
59
70
 
60
71
  The vertical is **platform-shared with NYCHH as the first (and today only) tenant**: the Core
61
72
  surfaces and the stage theme tokens are neutral/shared, and only the TableView and the navigation
@@ -268,34 +279,85 @@ This closes the "no BUTTON_BAR surface" gap the first build left open.
268
279
  through the new `useColumnVisibilityModalConfig` hook — see
269
280
  [column-visibility](./column-visibility.md).
270
281
 
271
- ## Create Transfer Order modal — UI built, data layer deliberately NOT faked
282
+ ## Create Transfer Order modal — the lock is on the PURCHASE ORDER, not the location
272
283
 
273
- `view/CreateTransferOrderModal/` (+ `hooks/useTransferableItemRows.ts`), opened from the header
274
- button seeded above. The interaction rules are the durable part:
284
+ `view/CreateTransferOrderModal/`, opened from the header button seeded above. Rebuilt **2026-09-01**
285
+ against the live Claude Design export; the three data-layer blockers below are resolved and the
286
+ picker is live.
275
287
 
276
- - **Single-source lock.** The first checked row fixes the source location for the whole order.
277
- Source Location is **derived and read-only — never picked**; the Location filter is pinned and
278
- disabled while locked, and **restores its pre-lock value** when the lock releases.
279
- - **A row is an ITEM AT A LOCATION**, kept only where availability >= 1.
288
+ > **⚠ Supersedes the earlier "single-source lock" rule.** Source Location is **no longer derived
289
+ > from row selection**. Read the two rules below as a replacement, not an addition.
290
+
291
+ - **Source Location is fixed from the moment the modal opens** — it is the single active location
292
+ with **`allowSending = 1`**, resolved by its own hook, and read-only. Nothing the user checks
293
+ changes it.
294
+ - **The lock moved to the PURCHASE ORDER: every line on one transfer must come from the same PO.**
295
+ The binding is **bidirectional** — picking a PO filters the item list to that PO's lines, and
296
+ checking an item **locks the PO select** to that item's PO. Unlocking **restores the pre-lock
297
+ value**, the same restore behaviour the old location lock had.
298
+ - **The picker columns dropped Location, Unit Value and Total** — Location is now redundant (there
299
+ is only one source) and the money columns are out of v1 scope.
280
300
  - **Dirty means "different from the state the form OPENED IN"**, not "different from empty" — so a
281
301
  pre-scoped open is clean, which is what makes the discard guard trustworthy.
282
302
  - **Qty error state machine:** touched on first keystroke; the error surfaces on **blur** only if
283
303
  touched; then it live-revalidates.
284
304
  - **The Review gate names the FIRST unmet condition** rather than a generic "incomplete".
285
305
 
286
- **Three open questions block the data layer** (the picker + fetch are written but **disabled behind
287
- a flag** — nothing is stubbed with fake data):
306
+ New hooks/components: `hooks/usePurchaseOrderItemRows.ts`, `hooks/useTargetLocationOptions.ts`,
307
+ `hooks/useTransferSourceLocation.ts`, `hooks/useLocationContacts.ts`,
308
+ `hooks/useCreateLocationContact.ts`, `view/AddContactModal.tsx`, `view/TransferNotesField.tsx`.
309
+ **Deleted:** `hooks/useTransferableItemRows.ts`, `hooks/usePurchaseOrderOptions.ts`.
310
+
311
+ ### The three original blockers
312
+
313
+ 1. **~~Availability per (item, location) has no API~~ — the QUESTION dissolved, but the DATA does
314
+ not exist.** `PurchaseOrderItems._qty*` are per-PO-line `FIELD_SQL` fields and base availability
315
+ bottoms out at **item receipts**, not unit dispositions — so the belief that this needed a
316
+ `UnitDispositions` slug (and that NYCHH's all-NULL `unitDispositionId` blocked it) was **wrong**,
317
+ and no new API was needed. **However NYCHH's deployed tenant override returns 0 for every line**
318
+ — see the blocker below.
319
+ 2. **~~The target-location picker is a TREE~~ — resolved: Locations are flat, so it is a select.**
320
+ No parent FK exists; the design's drill-down was dropped.
321
+ 3. **Commit flips unit dispositions to In Transit** — still open (API vs. worker ownership).
322
+
323
+ ### 🚨 OPEN BLOCKER — the NYCHH picker has zero selectable rows, and it is not a frontend fix
324
+
325
+ The picker keeps only lines with availability >= 1. For NYCHH there are **none**, in sandbox **and**
326
+ production.
327
+
328
+ `_Model_Nychh_PurchaseOrderItem` (deployed on `_production` and `_sandbox-client`, absent on
329
+ `_beta`/`_sandbox-dev`) sources "stock in" from inventory adjustments and `INNER JOIN`s
330
+ `InventoryAdjustmentItems.itemFulfillmentItemId`. **That column is NULL on 100% of rows** — 292 of
331
+ 292 in sandbox, 317 of 317 in production — so `_qtyReceived`, `_qtyOnHand` and `_qtyAvailable` are
332
+ all `0.00` for every NYCHH purchase-order line. **0 production PO lines report qty > 0.**
333
+
334
+ Resolution is an **integration / data-owner decision**, not UI work: either the NetSuite integration
335
+ backfills `InventoryAdjustmentItems.itemFulfillmentItemId`, or the override's stock-in source
336
+ changes (base PO item receipts return real numbers today). Until then, do **not** treat an empty
337
+ picker here as a frontend or ACL defect. Full evidence and both options:
338
+ [NYCHH transfer-order inventory quantities](../../../../clients/nychh/features/transfer-order-inventory-quantities.md).
339
+
340
+ > The `Client/2026-09-01a` role-3 `_qty*` grant is **not** the fix for this and was verified not to
341
+ > change the response — an explicit `fields=` request bypasses `AclFieldPermissions` entirely.
342
+
343
+ ### ⚠ `allowReceiving` is unmaintained — do NOT filter the target dropdown on it
344
+
345
+ Verified on NYCHH 2026-09-01: exactly **1 of 31** active locations sets `allowReceiving`, and it is
346
+ **the same location** that sets `allowSending = 1` — the source warehouse. Filtering targets on
347
+ `allowReceiving` therefore offers exactly one option: **the one place you cannot transfer to.**
288
348
 
289
- 1. **Availability per (item, location) has no API.** `Item._qtyAvailable` is item-level with no
290
- location dimension; the real source is `Units` (carries `itemId` + `locationId` + a
291
- disposition). Unresolved: which `UnitDispositions` slug counts as available, and whether to
292
- aggregate client-side or add a grouped table view. NYCHH has **3,226 active items**, so
293
- browser-side grouping will not hold.
294
- 2. **The design's target-location picker is a TREE** (breadcrumbs, drill-down, ancestor path), but
295
- **no parent FK was found on `_Model_Client_Location`**. If Locations are flat, the dialog has to
296
- become a searchable select and the design needs revisiting. Picker left disabled.
297
- 3. **Commit flips unit dispositions to In Transit — a real inventory mutation.** Unresolved whether
298
- the API owns that or a worker does.
349
+ The dropdown lists **all active locations minus the source**. Treat `allowReceiving` as a column
350
+ nobody populates until someone proves otherwise per tenant.
351
+
352
+ Related data-shape facts found the same day:
353
+
354
+ - **Contacts are sparse** — only **2 of 31** NYCHH locations have any, which is why the modal ships
355
+ an inline **Add Contact** path (`AddContactModal.tsx` + `useCreateLocationContact`).
356
+ - **The populated join table is `Contacts_Locations`** (route `/contact-locations`, **Record 76**) —
357
+ **not** `Locations_Contacts`. Binding the reversed name finds nothing.
358
+ - **`TransferOrders` has NO notes/description column.** The notes field's persistence is an **open
359
+ decision**: either `TransferOrders.description varchar(255)` (mirroring `SalesOrders`) or a
360
+ `TransferOrderNotes` child table (mirroring `SalesOrderNotes`). Nothing is written yet.
299
361
 
300
362
  ### Line-item fetch is off behind a FLAG, not commented out
301
363
 
@@ -357,8 +419,42 @@ visible in Core (neutral default; other tenants populate `TransferOrders.purchas
357
419
  must not be committed there. Origin/destination were picked by co-prime strides over ranked active
358
420
  `Locations` so they never collide, and the stage resolved via a ranked CTE rather than hardcoded ids.
359
421
 
422
+ ## ⚠ Two ways a hook renders an EMPTY dropdown with NOTHING in the network tab
423
+
424
+ Both were hit building this modal, both look identical in the browser, and neither logs anything.
425
+ When a select is empty **and DevTools shows no request at all**, it is one of these two — not a
426
+ backend problem. Check them before touching the API.
427
+
428
+ **1. A numeric value in `where` throws inside `buildUrl`, so React Query never fires.**
429
+ blox's `urlEncode(input: string)` calls `input.replace()`, so a number throws
430
+ `input.replace is not a function` **synchronously inside `queryFn`**. React Query swallows it as a
431
+ query error — no request is ever sent. **Every `where` value must be a STRING:**
432
+
433
+ ```ts
434
+ // WRONG — throws in buildUrl, no HTTP request, silent empty dropdown
435
+ where: { "Locations.isActive": { "=": 1 } }
436
+
437
+ // CORRECT
438
+ where: { "Locations.isActive": { "=": "1" } }
439
+ ```
440
+
441
+ Library-side detail: [blox API client](../../toga-blox/features/api-client.md).
442
+
443
+ **2. A non-zero `staleTime` lets a cache entry from a PREVIOUS SESSION count as fresh.**
444
+ `App.tsx` wraps the app in `PersistQueryClientProvider` with a `createSyncStoragePersister` on the
445
+ `localStorage` key **`supply-chain-query-cache`**, so the query cache **survives reloads and
446
+ sessions**. The `QueryClient` default is `staleTime: 0` for exactly this reason. A hook that sets
447
+ its own `staleTime: 5 * 60 * 1000` will happily serve a **stale persisted entry from yesterday** and
448
+ never fetch.
449
+
450
+ **Rule: data hooks in this app use `staleTime: 0`.** Three of this modal's new hooks shipped with
451
+ `5 * 60 * 1000` and had to be corrected. (`staleTime: Infinity` on the **surface-meta** hooks is a
452
+ deliberate, separate exception — see [surface-frontend](./surface-frontend.md).)
453
+
360
454
  ## Gotchas
361
455
 
456
+ - **⚠ An empty dropdown with no request in the network tab is a numeric `where` value or a
457
+ non-zero `staleTime`** — see the section above. Neither logs anything.
362
458
  - **A missing `AclRecordPermissions` grant on a joined record deletes a column, not a request.** The
363
459
  Status column would have silently vanished; record 351 had no grants at all.
364
460
  - **A missing `AclFieldPermissions` grant on the DEFAULT-SORT column is a 500, not a 403.** The
@@ -383,6 +479,29 @@ visible in Core (neutral default; other tenants populate `TransferOrders.purchas
383
479
  earlier migration without names, so a name-based lookup on them finds nothing.
384
480
 
385
481
  ## Change history
482
+ - 2026-09-01 — **Rebuilt the Create Transfer Order modal against the live Claude Design export and
483
+ closed two of its three data-layer blockers.** The **single-source lock is superseded**: Source
484
+ Location is now the one active `allowSending = 1` location, read-only from open and never derived
485
+ from row selection; the lock moved to the **PURCHASE ORDER** (all lines must share one PO,
486
+ bidirectional — picking a PO filters items, checking an item locks the PO select, unlock restores
487
+ the pre-lock value). Picker dropped the Location / Unit Value / Total columns. Blocker 1's
488
+ *question* dissolved — per-PO-line availability comes from `PurchaseOrderItems._qty*`, which bottom
489
+ out at **item receipts, not unit dispositions**, so NYCHH's all-NULL `unitDispositionId` was never
490
+ relevant; blocker 2 dissolved — Locations are flat, so the tree picker became a select.
491
+ 🚨 **But recorded a NEW open blocker: the NYCHH picker has zero selectable rows** — the deployed
492
+ `_Model_Nychh_PurchaseOrderItem` INNER JOINs `InventoryAdjustmentItems.itemFulfillmentItemId`,
493
+ which is NULL on 100% of rows (292/292 sandbox, 317/317 prod), so every PO line reports
494
+ availability 0. That needs an integration/data-owner decision, not UI work, and the
495
+ `Client/2026-09-01a` role-3 grant is **not** its fix. Recorded that
496
+ **`allowReceiving` is unmaintained** (1 of 31 NYCHH locations, and it is the source warehouse — so
497
+ filtering targets on it offers only the one place you cannot ship to), that contacts are sparse
498
+ (2 of 31, hence the inline Add Contact path), that the populated join is **`Contacts_Locations`**
499
+ (record 76), and that **`TransferOrders` has no notes column** — persistence still an open choice
500
+ between `description varchar(255)` and a `TransferOrderNotes` child table. Also added the
501
+ **two silent empty-dropdown causes**: a numeric `where` value throws inside blox's
502
+ `urlEncode`/`buildUrl` so no request is sent, and a non-zero `staleTime` serves a previous
503
+ session's entry out of the persisted `supply-chain-query-cache` — data hooks here must use
504
+ `staleTime: 0`. (apeterson)
386
505
  - 2026-08-31 — Recorded the **run state after sandbox-client was reset from production**: the three
387
506
  `Client_Nychh` layers and the `TransferOrderItems` timestamp columns are already present there (so
388
507
  those files insert 0 rows), the two **Core** seeds have not run, `Client/2026-08-28a` has run on
@@ -5,5 +5,5 @@
5
5
  | [NYCHH inventory-adjustment → item-fulfillment linking (direct FK columns, REST)](features/netsuite-inventory-adjustment-fulfillment-link.md) | 1.0 | For NYCHH stock adjustments, the NetSuite **inventory adjustment** carries a link to the **Item Fulfillment** it corrects, on the multi-select body custom field | 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_hh.php, worker/crons/toga2/netsuite/diagnose_hh_import_inventory_adjustment.php |
6
6
  | [NYCHH NetSuite → TransferOrders import (stocking-flag routing, PO bridges, origin location)](features/netsuite-transfer-order-import.md) | 1.0 | NYCHH's NetSuite **transfer orders are represented as NetSuite sales orders** (same as the GroWrk pattern), so the shared NetSuite → TOGa Supply importer (`App_ | library/app/api/toga2.php, library/app/api/netsuite/rest.php, library/app/netsuite.php, worker/crons/toga2/netsuite/sync_togasupply_hh.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/diagnose_hh_stuck_transfer_order.php, dbchanges2/Core/2026-08-27a - TransferOrderUuidSearchableIdentifier.sql, dbchanges2/Client/2026-08-28a - TransferOrderItemsTimestampColumns.sql, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestampColumnsReposition.sql, dbchanges2/Client/2026-08-29 - ItemReceiptTransferOrderIdFieldPermission.sql, dbchanges2/Client/2026-08-29b - ItemReceiptItemTransferOrderItemIdFieldPermission.sql |
7
7
  | [NYCHH PO links are UPSTREAM — the downstream SO→PO route returns empty](features/po-number-upstream-direction.md) | 2.0 | NYCHH's sales orders are created **from the customer's purchase order**, so their SO↔PO links live in the **upstream** table `PurchaseOrders_SalesOrders` (route | _underscore/Model/Client/SalesOrder.php, _underscore/Trait/Netsuite/SalesOrder.php, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/hooks/usePurchaseOrderDetails.ts, dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql, dbchanges2/Client/2026-08-25 - SalesOrderPurchaseOrdersFieldAllClients.sql |
8
- | [NYCHH transfer-order 2.0 model — inventory quantities + V2 field enablement](features/transfer-order-inventory-quantities.md) | 2.0 | The 2.0 (`_underscore`) side of NYCHH transfer-order support: a **two-branch inventory quantity model** on the NYCHH `Item` override, plus the **client-override | _underscore/Model/Nychh/Item.php, _underscore/Model/Nychh/PurchaseOrderItem.php, _underscore/Model/Nychh/TransferOrder.php, _underscore/Model/Nychh/TransferOrderStage.php, _underscore/Model/Client/TransferOrder.php, _underscore/Model/Client/TransferOrderItem.php, dbchanges2/Client_Nychh/2026-08-27a - TransferOrderCustomFieldWritePermission.sql, dbchanges2/Client_Nychh/2026-08-27b - TransferOrderStageStatusIdentifier.sql, dbchanges2/Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql |
9
- | [NYC Health & Hospitals](profile.md) | 2.0 | NYC Health & Hospitals (NYCHH) is a TOGA 2.0 client on the `_underscore` platform, prod schema `Client_Nychh`. | dbchanges2/Client_Nychh/2026-08-31a - PurchaseOrdersTableViewRebase.sql, dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_Nychh/2026-08-24 - FixInventoryGroupingsUnitsTopologyOverrideIds.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, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql |
8
+ | [NYCHH transfer-order 2.0 model — inventory quantities + V2 field enablement](features/transfer-order-inventory-quantities.md) | 2.0 | The 2.0 (`_underscore`) side of NYCHH transfer-order support: a **two-branch inventory quantity model** on the NYCHH `Item` override, plus the **client-override | _underscore/Model/Nychh/Item.php, _underscore/Model/Nychh/PurchaseOrderItem.php, _underscore/Model/Nychh/TransferOrder.php, _underscore/Model/Nychh/TransferOrderStage.php, _underscore/Model/Client/TransferOrder.php, _underscore/Model/Client/TransferOrderItem.php, dbchanges2/Client_Nychh/2026-08-27a - TransferOrderCustomFieldWritePermission.sql, dbchanges2/Client_Nychh/2026-08-27b - TransferOrderStageStatusIdentifier.sql, dbchanges2/Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql, dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql, _underscore/Model/Client/PurchaseOrderItem.php |
9
+ | [NYC Health & Hospitals](profile.md) | 2.0 | NYC Health & Hospitals (NYCHH) is a TOGA 2.0 client on the `_underscore` platform, prod schema `Client_Nychh`. | dbchanges2/Client_Nychh/2026-08-31a - PurchaseOrdersTableViewRebase.sql, dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_Nychh/2026-08-24 - FixInventoryGroupingsUnitsTopologyOverrideIds.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, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql, dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: nychh
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-31
9
+ updated: 2026-09-01
10
10
  owners: [jcardinal, apeterson]
11
11
  files:
12
12
  - _underscore/Model/Nychh/Item.php
@@ -19,6 +19,8 @@ files:
19
19
  - dbchanges2/Client_Nychh/2026-08-27b - TransferOrderStageStatusIdentifier.sql
20
20
  - dbchanges2/Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql
21
21
  - dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql
22
+ - dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql
23
+ - _underscore/Model/Client/PurchaseOrderItem.php
22
24
  related:
23
25
  - ../../../2.0/apps/_underscore/features/acl-permission-chain.md
24
26
  - ../../../2.0/apps/api2/features/v2-api-error-codes.md
@@ -123,6 +125,151 @@ hardening (not done): a `UNIQUE(purchaseOrderItemId, salesOrderItemId)` on the b
123
125
  `PurchaseOrderItems_TransferOrderItems`, which is currently **empty (0 rows)** in prod — so PO-level
124
126
  outbound reads 0 until the transfer-order import populates that bridge. Separate import gap, not this change.
125
127
 
128
+ ### 🚨 The override is NOT on every branch — NYCHH PO-line quantities differ per environment
129
+
130
+ Verified 2026-09-01 with `git ls-tree` against each `origin/` branch of `_underscore`:
131
+
132
+ | Branch | `Model/Nychh/PurchaseOrderItem.php` | Effective `_qtyReceived` for NYCHH |
133
+ |---|---|---|
134
+ | `_production` | **present** | inventory adjustments → **0.00 today** |
135
+ | `_sandbox-client` | **present** | inventory adjustments → **0.00 today** |
136
+ | `_beta` | **absent** | falls back to base → **item receipts** |
137
+ | `_sandbox-dev` | **absent** | falls back to base → **item receipts** |
138
+
139
+ Because `_underscore` is **pulled at deploy time** by branch (`.platform/hooks/prebuild/git.sh`),
140
+ the same API request returns a **different number** depending on which tier answers it. On beta and
141
+ sandbox-dev the base `_Model_Client_PurchaseOrderItem` runs and reports real received quantities;
142
+ on production and sandbox-client the NYCHH override runs and reports 0.00 by design.
143
+
144
+ **This is the single most misleading thing about debugging these fields.** Checking your local
145
+ working tree tells you nothing — see the worked case below.
146
+
147
+ ### The BASE `_Model_Client_PurchaseOrderItem` chain (what NYCHH falls back to)
148
+
149
+ Worth having written down, because it is the formula that runs wherever the override is absent, and
150
+ because it is what a hand-run SQL check reproduces unless you deliberately pick the override. All
151
+ are `FIELD_SQL` correlated subqueries — **none is a stored column**
152
+ (`_underscore/Model/Client/PurchaseOrderItem.php`, ~L41–158):
153
+
154
+ | Field | Base definition |
155
+ |---|---|
156
+ | `_qtyReceived` | `SUM(ItemReceiptItems.quantity)` where `purchaseOrderItemId = PurchaseOrderItems.id` |
157
+ | `_qtyFulfilled` | `SUM(ItemFulfillmentItems.quantity)` through `PurchaseOrderItems_TransferOrderItems → ItemFulfillments → ItemFulfillmentStages → ItemFulfillmentStatuses`, restricted to status slug **`SHIPPED`** (i.e. shipped on a linked **transfer** order) |
158
+ | `_qtyOnHand` | `_qtyReceived − _qtyFulfilled` |
159
+ | `_qtyCommitted` | `SUM(SalesOrderItems.qtyCommitted) − SUM(shipped ItemFulfillmentItems.quantity)`, both reached via `PurchaseOrders_SalesOrders` and matched on `VendorItems.id = PurchaseOrderItems.vendorItemId` — **matched by VENDOR ITEM, not by PO line** |
160
+ | `_qtyAvailable` | `_qtyOnHand − _qtyCommitted` |
161
+
162
+ **Two things follow that were previously believed otherwise:**
163
+
164
+ - **Base availability bottoms out at ITEM RECEIPTS, not unit dispositions.** This dissolves the
165
+ earlier blocker that "availability needs `Units` + a `UnitDispositions` slug" — NYCHH units all
166
+ carry `NULL unitDispositionId`, which turned out to be irrelevant to these fields. It is what
167
+ unblocked the Create Transfer Order item picker (see
168
+ [Transfer Orders page](../../../2.0/apps/toga25-supply/features/transfer-orders-page.md)).
169
+ - **`_qtyCommitted` matches by vendor item, not by PO line**, so at base it can attribute
170
+ commitment from a *different* PO line carrying the same `vendorItemId`. The NYCHH override
171
+ replaces it with a PO-line-scoped Transfer-Order commitment precisely to avoid this.
172
+
173
+ ### ⚠ Worked case — "the API returns 0 but the SQL returns 40" (diagnosed 2026-09-01)
174
+
175
+ `GET /v2/purchase-order-items` returned `_qtyReceived/_qtyOnHand/_qtyFulfilled/_qtyAvailable: 0`
176
+ for every line on sandbox-client, while the *same*-looking subquery run by hand on the *same* host
177
+ returned **40.00**.
178
+
179
+ **Cause: the two were never the same SQL.** Both formulas run against the reference row
180
+ (sandbox `Client_Nychh`, `PurchaseOrderItems.id = 196`, uuid
181
+ `01110c53-ba73-e60a-d94c-0ccf77327c0e`, PO 80622 line 1):
182
+
183
+ | Expression | `_qtyReceived` |
184
+ |---|---|
185
+ | base `_Model_Client_PurchaseOrderItem::_qtyReceived` (item receipts) | **40.00** |
186
+ | `_Model_Nychh_PurchaseOrderItem::_qtyReceived` (inventory adjustments) | **0.00** |
187
+
188
+ The hand-run check used the base formula; the API ran the NYCHH override. **The API was reporting
189
+ correctly** — but see the blocker below, because "correct" here means "0 for every line".
190
+
191
+ **Why the override was wrongly eliminated:** `_underscore/Model/Nychh/` was listed in the **local
192
+ working tree**, which sits on branch `features-talos-transferorders` and does not carry the file.
193
+ Branch drift *was* also checked — but only on `Model/Client/PurchaseOrderItem.php`, which is
194
+ byte-identical across all four branches. **That passing sibling check is what made the wrong
195
+ conclusion feel safe.** The drift was one directory over.
196
+
197
+ **The generalisable rules:**
198
+
199
+ 1. **Check a tenant model override on the DEPLOYED branch, never on the local working tree.**
200
+ ```
201
+ git cat-file -e origin/<branch>:Model/<Slug>/<Model>.php && echo present || echo absent
202
+ ```
203
+ A feature branch is not evidence about production.
204
+ 2. **A base file being byte-identical across branches rules out drift in THAT FILE ONLY.** It says
205
+ nothing about `Model/<Tenant>/`. Re-run the check there before writing the elimination down —
206
+ a clean sibling result is the trap, not the reassurance.
207
+ 3. **A calculated field disagreeing with hand-run SQL means two different expressions are running.**
208
+ Identify the emitting class first; ACL, `calcDepth`, wrong DB and deploy lag are all downstream
209
+ of that and were each eliminated here at real cost.
210
+
211
+ Also eliminated and worth not repeating: ACL (bypassed entirely on the explicit-`fields=` path —
212
+ see [v2-rest-query-contract](../../../2.0/apps/api2/features/v2-rest-query-contract.md)),
213
+ `calcDepth` (echoed 3; the gate at `V2.php:7231` is `depth <= calcDepth`), wrong database
214
+ (`Core.DatabaseHosts` maps "Sandbox Client" for `Client_Nychh` to
215
+ `client.sandbox.database.togahub.com`, matching the API the frontend targets), deploy lag (the base
216
+ `_qtyReceived` formula is unchanged across the 2026-08-19 rewrite), wrong table
217
+ (`_Model_Client_ItemReceiptItem::TABLE = 'ItemReceiptItems'`), and missing data (sandbox has 823 PO
218
+ lines, 207 `ItemReceiptItems`, 122 lines with available >= 1).
219
+
220
+ ### 🚨 OPEN BLOCKER — the override reads 0 for 100% of NYCHH PO lines, in sandbox AND production
221
+
222
+ The override's chain `INNER JOIN`s
223
+ `InventoryAdjustmentItems ON itemFulfillmentItemId = ItemFulfillmentItems.id`. **That column is
224
+ NULL on every row in both environments** (verified 2026-09-01):
225
+
226
+ | Environment | `InventoryAdjustmentItems` rows | rows with `itemFulfillmentItemId` set |
227
+ |---|---|---|
228
+ | sandbox `Client_Nychh` | 292 | **0** |
229
+ | production `Client_Nychh` | 317 | **0** |
230
+
231
+ Production PO lines the override would report with qty > 0: **0, out of all lines.**
232
+
233
+ An INNER JOIN makes an unlinked adjustment contribute nothing, so `_qtyReceived` — and therefore
234
+ `_qtyOnHand` and `_qtyAvailable` — is **0.00 for every NYCHH purchase-order line today.**
235
+
236
+ This is precisely the "Old-data caveat" the override file states in its own header comment (NetSuite
237
+ began linking inventory adjustments to fulfillments less than a year ago; unlinked adjustments carry
238
+ a NULL `itemFulfillmentItemId` and are excluded until the integration backfills them). **It is not a
239
+ corner case — it is currently 100% of the data.**
240
+
241
+ **Consequence: the Create Transfer Order item picker cannot show a single selectable line for
242
+ NYCHH.** The picker keeps only rows with availability >= 1, and there are none. This is
243
+ **unresolved and needs a decision from the integration / data owner** — it is *not* a frontend fix.
244
+ Two options:
245
+
246
+ - **(a)** the NetSuite integration backfills `InventoryAdjustmentItems.itemFulfillmentItemId`, or
247
+ - **(b)** the override's definition of "stock in" is changed (e.g. back to PO item receipts, which
248
+ return real numbers today — 40.00 on the reference row).
249
+
250
+ Until one of those lands, treat any NYCHH transfer-order availability figure as **structurally 0**,
251
+ not as a bug to debug. Tracking the adjustment↔fulfillment link:
252
+ [NYCHH inventory-adjustment → item-fulfillment linking](./netsuite-inventory-adjustment-fulfillment-link.md).
253
+
254
+ ### The role-3 grant migration is NOT the fix for the empty picker
255
+
256
+ `dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql` grants **role 3 (API)**
257
+ READ (`isWritable = 0`) on all eight `_qty*` `RecordFields` (**1500–1504, 1524–1526**), guarded by
258
+ `NOT EXISTS` on the `(recordFieldId, roleId)` unique key.
259
+
260
+ **It was written against a wrong theory and was verified NOT to change the response** — an explicit
261
+ `fields=_qtyX` request never consults `AclFieldPermissions` at all. Nothing about the empty picker
262
+ is a permission problem; the cause is the data gap in the section above.
263
+
264
+ **Keep it anyway.** It closes a real and *separate* gap on the `getFullModelData()` (no-explicit-
265
+ `fields=`) path, where these fields genuinely are ACL-gated, and 31 of 36 prod tenants have the same
266
+ hole. It was **applied on sandbox `Client_Nychh`** and is **cleanly reversible** via its eight
267
+ hardcoded uuid literals (`DELETE … WHERE uuid IN (…)`) — which is why it uses literals rather than
268
+ generated uuids; see
269
+ [re-runnable additive migrations](../../../2.0/apps/dbchanges2/features/rerunnable-additive-inserts.md).
270
+ Fleet survey and the "grant explicitly, don't mirror a sibling" rule it produced:
271
+ [ACL permission chain](../../../2.0/apps/_underscore/features/acl-permission-chain.md).
272
+
126
273
  ### Index coverage / schema
127
274
 
128
275
  No `dbchanges2` file — pure calculated `FIELD_SQL` in PHP. All correlation columns
@@ -226,8 +373,42 @@ pattern (default a NOT NULL non-writable column in the model, never send it) is
226
373
  - **A 0.00 quantity for NYCHH is expected today, not a bug.** Stock-in reads inventory-adjustment links
227
374
  that are not backfilled yet, and PO-level outbound reads a still-empty
228
375
  `PurchaseOrderItems_TransferOrderItems` bridge (see above).
376
+ - 🚨 **Every NYCHH PO line reads `_qtyReceived`/`_qtyOnHand`/`_qtyAvailable` = 0.00 right now**, in
377
+ sandbox **and** production, because `InventoryAdjustmentItems.itemFulfillmentItemId` is NULL on
378
+ 100% of rows and the override INNER JOINs on it. Blocks the Create Transfer Order picker; needs an
379
+ integration/data decision, not code. See the OPEN BLOCKER section.
380
+ - **⚠ `_Model_Nychh_PurchaseOrderItem` is on `_production`/`_sandbox-client` but NOT on
381
+ `_beta`/`_sandbox-dev`** — so NYCHH PO-line quantities are genuinely different numbers per tier.
382
+ Establish the deployed `_underscore` branch before comparing environments or trusting a repro.
383
+ - **Never eliminate a tenant model override by `ls`-ing your working tree.** Use
384
+ `git cat-file -e origin/<deployed-branch>:Model/<Slug>/<Model>.php`. This exact mistake cost a
385
+ session — and a *passing* drift check on the base `Model/Client/` file is what made it feel safe.
229
386
 
230
387
  ## Change history
388
+ - 2026-09-01 — **Diagnosed the "API returns 0, hand-run SQL returns 40" report, and it turned into a
389
+ data blocker rather than a bug.** The API ran `_Model_Nychh_PurchaseOrderItem`'s
390
+ inventory-adjustment `_qtyReceived` while the hand-run check used the **base** item-receipt
391
+ formula — on the reference row (`PurchaseOrderItems.id 196`, PO 80622 line 1) the base returns
392
+ **40.00** and the override **0.00**. 🚨 **The override returns 0 for 100% of NYCHH PO lines in
393
+ both sandbox and production**: it INNER JOINs
394
+ `InventoryAdjustmentItems.itemFulfillmentItemId`, which is NULL on **all** rows (292 sandbox /
395
+ 317 prod, 0 linked; 0 prod PO lines report qty > 0). The file's own "old-data caveat" is
396
+ currently the entire dataset, so the **Create Transfer Order item picker cannot show any
397
+ selectable NYCHH line** — recorded as an **open blocker needing an integration/data-owner
398
+ decision** (backfill the FK, or change the override's stock-in source), explicitly not a frontend
399
+ fix. Also found `Model/Nychh/PurchaseOrderItem.php` present on **`_production` + `_sandbox-client`**
400
+ and **absent from `_beta` + `_sandbox-dev`** (verified with `git cat-file -e`), so PO-line
401
+ quantities differ per tier; the override had been wrongly eliminated by listing the **local**
402
+ working tree on a feature branch that lacks it, while the drift check covered only
403
+ `Model/Client/PurchaseOrderItem.php` — the passing sibling check is what made the wrong conclusion
404
+ feel safe. Documented the **base `_qty*` chain** (received = `ItemReceiptItems`; fulfilled =
405
+ SHIPPED transfer-order fulfillments; committed matched **by vendor item, not PO line**), which
406
+ establishes that base availability bottoms out at **item receipts, not unit dispositions** —
407
+ dissolving the belief that NULL `unitDispositionId` was the blocker. Corrected the framing of
408
+ `Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql`: it is **not** the fix for the
409
+ empty picker and was verified not to change the response (explicit `fields=` bypasses ACL); it
410
+ remains a real separate gap on the `getFullModelData()` path, was applied on sandbox
411
+ `Client_Nychh`, and is cleanly reversible via its eight hardcoded uuid literals. (apeterson)
231
412
 
232
413
  - 2026-08-31 — **Stock-in flipped from PO item receipts → inventory adjustments** (per the 2026-08-27
233
414
  TOGa Supply meeting: NetSuite has no stocking concept, stock is added only by an inventory adjustment
@@ -26,6 +26,7 @@ files:
26
26
  - dbchanges2/Client_Nychh/2026-08-28b - TransferOrdersNavigationEnable.sql
27
27
  - dbchanges2/Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql
28
28
  - dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql
29
+ - dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql
29
30
  related:
30
31
  - ../../2.0/apps/_underscore/features/tracking-number-bridges.md
31
32
  - ../../2.0/apps/_underscore/features/tableview-joins.md
@@ -99,8 +100,9 @@ table views. Client-specific DB change-sets live in `dbchanges2/Client_Nychh/`.
99
100
  `Client_Nychh/`**, because the client files reference Core record ids and the NYCHH TableView/ACL
100
101
  need the columns the fan-out files add. Run state per environment must be **verified against that
101
102
  environment**, never assumed from the repo. **beta is blocked** because it lacks the
102
- `TransferOrderStages` table. A **Create Transfer Order** modal is started (UI built, data
103
- layer flagged off pending three open data-model questions). See
103
+ `TransferOrderStages` table. The **Create Transfer Order** modal was reworked 2026-09-01 and its
104
+ data layer is wired, but 🚨 **the item picker shows zero selectable lines for NYCHH** — see the
105
+ availability blocker below. See
104
106
  [Transfer Orders page](../../2.0/apps/toga25-supply/features/transfer-orders-page.md).
105
107
  - 🚨 **The transfer-order ACL + schema on prod/sandbox are UNTRACKED (2026-08-28).** Record and field
106
108
  ACL for records 312/313, their logic-group/expression chain, and the
@@ -114,6 +116,32 @@ table views. Client-specific DB change-sets live in `dbchanges2/Client_Nychh/`.
114
116
  fulfilled/backordered model on `_Model_Nychh_Item`, plus the client-override models + ACL rows that
115
117
  let a NetSuite TransferOrder POST succeed. See
116
118
  [NYCHH transfer-order inventory & quantities](./features/transfer-order-inventory-quantities.md).
119
+ - 🚨 **OPEN BLOCKER — every NYCHH PO line reports availability 0, in sandbox AND production
120
+ (verified 2026-09-01).** The deployed `_Model_Nychh_PurchaseOrderItem` sources stock-in from
121
+ inventory adjustments and `INNER JOIN`s `InventoryAdjustmentItems.itemFulfillmentItemId`, which is
122
+ **NULL on 100% of rows** (292/292 sandbox, 317/317 prod). So `_qtyReceived` / `_qtyOnHand` /
123
+ `_qtyAvailable` are all `0.00` for every purchase-order line, and the **Create Transfer Order item
124
+ picker has nothing to show**. Needs an **integration / data-owner decision** — backfill the FK, or
125
+ change the override's stock-in source (base PO item receipts return real numbers today). Not a
126
+ frontend or ACL defect, and **not** fixed by `Client/2026-09-01a`. See
127
+ [NYCHH transfer-order inventory & quantities](./features/transfer-order-inventory-quantities.md).
128
+ - 🚨 **`_Model_Nychh_PurchaseOrderItem` is NOT on every `_underscore` branch (verified 2026-09-01).**
129
+ `Model/Nychh/PurchaseOrderItem.php` exists on **`_production`** and **`_sandbox-client`** but is
130
+ **absent from `_beta`** and **`_sandbox-dev`**. Because `_underscore` is pulled at deploy by
131
+ branch, NYCHH PO-line `_qty*` values are **different numbers per tier**: prod/sandbox-client run
132
+ the inventory-adjustment formula (0.00 until the NetSuite link is backfilled), beta/sandbox-dev
133
+ fall back to the base item-receipt formula and report real quantities. **Never eliminate a tenant
134
+ override by `ls`-ing your working tree** — use
135
+ `git ls-tree -r --name-only origin/<branch>`. That mistake cost a full session chasing a
136
+ non-existent "API returns 0" bug. See
137
+ [NYCHH transfer-order inventory & quantities](./features/transfer-order-inventory-quantities.md).
138
+ - **Create Transfer Order modal is scoped by PURCHASE ORDER, not by location (2026-09-01).** Source
139
+ Location is the one active `allowSending = 1` location, read-only from open; all lines on a
140
+ transfer must share one PO. `Locations.allowReceiving` is **unmaintained** for this tenant (1 of 31
141
+ active locations, and it is the source warehouse), so the target dropdown lists all active
142
+ locations minus the source. Contacts are sparse (2 of 31) and the populated join is
143
+ **`Contacts_Locations`** (record 76), not `Locations_Contacts`. See
144
+ [Transfer Orders page](../../2.0/apps/toga25-supply/features/transfer-orders-page.md).
117
145
  - **Inventory-adjustment → item-fulfillment linking** — imports NetSuite `custbody_stock_adjustment`
118
146
  (adjustment→fulfillment) into two all-client bridges for adjustment→fulfillment→SO→PO traceability;
119
147
  gated NYCHH-only, best-effort, with on-demand fulfillment import. Go-live toggle
@@ -0,0 +1,109 @@
1
+ ---
2
+ type: session
3
+ slug: transfer-orders-qty-blocker
4
+ title: NYCHH Transfer Orders item picker empty — root-caused to the Nychh _qtyReceived override
5
+ author: apeterson
6
+ repos: [toga25-supply, dbchanges2, api2, _underscore]
7
+ framework: "2.0"
8
+ client: nychh
9
+ status: active
10
+ created: 2026-09-01
11
+ updated: 2026-09-01
12
+ ---
13
+
14
+ # Session: transfer-orders-qty-blocker
15
+ **Date:** 2026-09-01
16
+ **Project/Repo:** toga25-supply (2.0), with dbchanges2; api2 + _underscore read-only
17
+ **Task:** Finish the NYCHH Create Transfer Order modal (contact picker, notes, PO select, item picker) and find out why the item picker renders empty even though `/purchase-order-items` returns 823 rows.
18
+
19
+ ---
20
+
21
+ ## What WORKED
22
+
23
+ - **ROOT CAUSE FOUND.** `_Model_Nychh_PurchaseOrderItem` overrides `_qtyReceived` to source stock-in from **inventory adjustments** instead of item receipts. Verified both formulas against the same row (sandbox `Client_Nychh`, `PurchaseOrderItems.id = 196`, uuid `01110c53-ba73-e60a-d94c-0ccf77327c0e`, PO 80622 line 1):
24
+ - base `_Model_Client_PurchaseOrderItem::_qtyReceived` → **40.00**
25
+ - `_Model_Nychh_PurchaseOrderItem::_qtyReceived` → **0.00**
26
+ - **Why the override yields 0** — its chain does `INNER JOIN InventoryAdjustmentItems ON itemFulfillmentItemId = ItemFulfillmentItems.id`, and that column is NULL on every row:
27
+ - sandbox `Client_Nychh`: 292 `InventoryAdjustmentItems`, **0** with `itemFulfillmentItemId` set
28
+ - production `Client_Nychh`: 317 `InventoryAdjustmentItems`, **0** with `itemFulfillmentItemId` set
29
+ - production PO lines the override reports with qty > 0: **0**
30
+ This is the override file's own documented "old-data caveat" (NetSuite only began linking adjustments to fulfillments within the last year) — currently it is 100% of the data, not a corner case.
31
+ - **Correct way to detect a tenant model override** — `git cat-file -e origin/<branch>:Model/<Slug>/<Model>.php`. Confirmed PRESENT on `origin/_sandbox-client` and `origin/_production`, absent on `origin/_beta` and `origin/_sandbox-dev`.
32
+ - **Documented the full `_qty*` formula chain** from `_underscore/Model/Client/PurchaseOrderItem.php` (lines 41–158): `_qtyReceived` = SUM(ItemReceiptItems.quantity); `_qtyFulfilled` = shipped ItemFulfillmentItems via `PurchaseOrderItems_TransferOrderItems`; `_qtyOnHand` = received − fulfilled; `_qtyCommitted` = SO qtyCommitted − shipped, matched by **vendor item** not PO line; `_qtyAvailable` = onHand − committed.
33
+ - **Fixed: numeric `where` values silently cancel the request.** blox `urlEncode(input: string)` calls `input.replace()`, so a numeric `1` throws inside `buildUrl` → `queryFn`. React Query swallows it and **no HTTP request is sent** — an empty dropdown with nothing in the network tab. Corrected to `{ "Locations.isActive": { "=": "1" } }` in `useTargetLocationOptions.ts` and `useTransferSourceLocation.ts`; the target-location and PO dropdowns then issued requests.
34
+ - **Fixed: non-zero `staleTime` suppresses fetches across sessions.** `App.tsx` uses `PersistQueryClientProvider` + `createSyncStoragePersister` on localStorage key `supply-chain-query-cache`, and the QueryClient default is `staleTime: 0`. Three new hooks had `staleTime: 5*60*1000`, letting a persisted entry from an earlier session count as fresh. All set to 0.
35
+ - **Ran `Core/2026-09-01a - TransferOrderCreatePoColumnAndFilter.sql` successfully on sandbox** — message 288, SurfaceElements 214 (PO column) + 215 (poFilter), absolute `CASE` sortOrder assignment, `isVisible = 0` on 209/211/213. Confirmed by the resulting sortOrder rows.
36
+ - **Wrote and ran `Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql`** on sandbox `Client_Nychh` — 8 role-3 grants landed, verified. (It is NOT the fix; see below.)
37
+ - **Fleet ACL survey (production, 36 client DBs)** — 31 grant all eight `_qty*` to role 1 and none to role 3; 4 (Compass, Imhouston, Mcgrawhill, Tdsynnex) have none at all; only Prudential (roles 1+3) and Northwell (role 3) are correct.
38
+ - **Verified `Core.Records`/`Core.RecordFields` ids are identical between production Core and sandbox Core** for record 18 — this is what makes literal id references safe in `Client/` migrations.
39
+ - **Knowledge captured and pushed** — commit `261d8e7` on `_main`, 9 docs, validate OK (32 repos).
40
+
41
+ ## What did NOT work — DO NOT RETRY THESE
42
+
43
+ - **Granting `AclFieldPermissions` to role 3 does NOT fix the empty picker.** The 8 grants landed and the response was byte-for-byte unchanged — still `_qtyAvailable: 0`. Reason: `V2.php:3338` classifies a field as calculated when `$modelFieldConfig[$f]['type'] == _Model::FIELD_SQL`, and that branch runs **before** the ACL branch at `:3353` and checks no permissions. An explicit `fields=_qtyAvailable` therefore bypasses `AclFieldPermissions` entirely. The ACL gate for calc fields is at `V2.php:7216`, which is the *no-explicit-fields* (`getFullModelData`) path only.
44
+ - **Checking `Core.AclFieldPermissions` for record 18 is meaningless.** `Core.Records.id = 18` has `aclDatabase = CLIENT`, so grants are read from `Client_<Tenant>.AclFieldPermissions`. I checked Core, got a confident answer, and it sent the investigation down a wrong path for a long stretch.
45
+ - **Eliminating the tenant model override by listing the local working tree.** `ls Model/Nychh/` showed 21 files and no `PurchaseOrderItem.php` — because the local checkout is on branch `features-talos-transferorders`, which does not carry it. The file **is** on the deployed branches. This single wrong elimination is why the root cause took so long.
46
+ - **Trusting the sibling drift check.** `git diff origin/_sandbox-client -- Model/Client/PurchaseOrderItem.php` was empty and all four branches are byte-identical — a true result that made the override elimination feel safe. The drift was one directory over, in `Model/Nychh/`.
47
+ - **Mirroring a sibling field's roles in an ACL grant migration.** Drafted it (matching house precedent `Client/2026-07-22c`) and rejected it before shipping: Compass grants `quantity` to **role 4 (Public)**, so the mirror would have handed Public read on eight derived inventory quantities, and Imhouston has no `quantity` grant so the mirror would have silently inserted nothing there.
48
+ - **Reading `Core.RecordFields` from a `Client/` migration.** First draft did this (copying `Client/2026-07-22c`); the `dbchanges2-cluster-isolation.js` PreToolUse hook blocked it, correctly. Verified on production that the client cluster (reader **and** writer endpoints) hosts only `Client_*` databases — no `Core`. `2026-07-22c` is a shipped violation, not a pattern to copy.
49
+ - **Inferring a table's column list from a sibling migration's VALUES row.** Produced `Unknown column 'slug' in 'field list'`. `Core.Messages` columns are `(id, uuid, dtCreated, messageKey, name, defaultValue)`.
50
+ - **`ON DUPLICATE KEY UPDATE id = id` on literal-id rows** — self-caught before running; violates the rule that a stolen team-assigned id must fail loudly. Replaced with `NOT EXISTS` guards on the natural key.
51
+ - **Relative `sortOrder + 1` shift guarded by a subquery on the table being updated** — MySQL error 1093 risk. Replaced with an idempotent absolute `CASE` assignment that needs no guard.
52
+ - **Flipping `[debug] show_queries` in `Config/sandbox-client.ini`** — was queued as the next diagnostic and is now unnecessary. There is nothing left to find in the query.
53
+ - **`ERROR 1054 Unknown column 'f.sql'` on `Core.RecordFields`** — the table has no `sql` column; calculated-field SQL lives in the `_underscore` model as `FIELD_SQL` static methods.
54
+ - **`SELECT ... slug FROM Roles`** — the client `Roles` table has no `slug` column (id, name only).
55
+
56
+ ## Not tried yet (candidates for next session)
57
+
58
+ - **Decide the stock-in source for NYCHH.** Either (a) get the NetSuite integration to backfill `InventoryAdjustmentItems.itemFulfillmentItemId`, or (b) change `_Model_Nychh_PurchaseOrderItem::_qtyReceived`. This is the actual blocker and is an integration/data-owner call (Jason owns the availability-decrement behavior).
59
+ - **Ask why the NYCHH override exists at all** — if item receipts are the truth for availability, the override may simply be wrong for this use case rather than needing a backfill.
60
+ - **Notes persistence** — `TransferOrders` has no notes/description column. Options: `TransferOrders.description varchar(255)` mirroring `SalesOrders`, or a `TransferOrderNotes` child table mirroring `SalesOrderNotes`.
61
+ - **Build the review step** — currently a placeholder ("Review step — not built yet"); the Create button is disabled.
62
+ - **Strip the diagnostics** — `_qtyReceived`/`_qtyFulfilled` were added to `PO_ITEM_FIELDS` purely to diagnose, and `sampleBreakdown` threads through to the empty-state message. Remove both once the blocker is resolved.
63
+ - **Decide whether `Client/2026-09-01a` should fan out to all tenants** or stay NYCHH-only. It is currently applied to sandbox `Client_Nychh` only.
64
+ - **`allowReceiving` data hygiene** — decide whether the flag should be maintained so the target dropdown can filter on it.
65
+ - **Flag `Client_Compass_20260831_120000`** — a dated backup database on the production client cluster that any `Client/` fan-out would hit.
66
+ - Parked, do not resume unasked: Line Items + Units columns, Target Contact column on the list table, the status 5→3 vocabulary relabel, Target Location path rendering, the location pin icon.
67
+
68
+ ## Current file state
69
+
70
+ | File | Status | Notes |
71
+ |------|--------|-------|
72
+ | `toga25-supply/.../CreateTransferOrderModal/CreateTransferOrderModal.tsx` | Modified, uncommitted | Bidirectional PO binding (`lockedPo`/`preLockPoRef`/`wasLockedRef`), search input first then PO select, `FILTER_ROLES` splits controls from columns, empty-state cascade names the real cause |
73
+ | `toga25-supply/.../hooks/usePurchaseOrderItemRows.ts` | Created, uncommitted | Fetches all PO lines, derives PO options; still carries the `_qtyReceived`/`_qtyFulfilled` + `sampleBreakdown` diagnostics |
74
+ | `toga25-supply/.../hooks/useTargetLocationOptions.ts` | Created, uncommitted | All active locations minus source; `where` values are strings; `staleTime: 0` |
75
+ | `toga25-supply/.../hooks/useTransferSourceLocation.ts` | Created, uncommitted | Resolves the single `allowSending` location; exports `formatLocationAddress`; returns `hasAmbiguousSource` |
76
+ | `toga25-supply/.../hooks/useLocationContacts.ts` | Created, uncommitted | Route `/contact-locations` (Record 76); `DEFAULT_CONTACT_TYPE = "SHIPPING"` |
77
+ | `toga25-supply/.../hooks/useCreateLocationContact.ts` | Created, uncommitted | POST `/contacts` then POST `/contact-locations` |
78
+ | `toga25-supply/.../view/AddContactModal.tsx` | Created, uncommitted | Two required names, capture-phase Escape, own scrim |
79
+ | `toga25-supply/.../view/TransferNotesField.tsx` | Created, uncommitted | `useAutoGrow`, `FieldClear`, Escape blurs |
80
+ | `toga25-supply/.../view/TransferItemPickerTable.tsx` | Modified, uncommitted | Type renamed `PurchaseOrderItemRow`; caller-owned `emptyMessage` |
81
+ | `toga25-supply/.../hooks/useTransferableItemRows.ts` | Deleted, uncommitted | Superseded |
82
+ | `toga25-supply/.../hooks/usePurchaseOrderOptions.ts` | Deleted, uncommitted | Superseded — options now derived from item rows |
83
+ | `dbchanges2/Core/2026-09-01a - TransferOrderCreatePoColumnAndFilter.sql` | Created, uncommitted | **RUN on sandbox, succeeded** |
84
+ | `dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql` | Created, uncommitted | **RUN on sandbox `Client_Nychh`, succeeded** — but is NOT the fix; reversible via its 8 uuid literals |
85
+ | `toga-tech/knowledge/**` (9 docs) | Committed + pushed | Commit `261d8e7` on `_main` |
86
+
87
+ `npx tsc --noEmit` from the toga25-supply root: exit 0, clean. Nothing committed or pushed in any project repo.
88
+
89
+ ## Decisions made
90
+
91
+ - **Grant role 3 explicitly rather than mirroring a sibling field's roles.** Rationale: mirroring `quantity` would have granted Compass's role 4 (Public) eight derived inventory quantities and would have inserted nothing on Imhouston. Rejected: the house precedent `Client/2026-07-22c`, which mirrors.
92
+ - **Use hardcoded v4 uuid literals, not `UUID()`, in fan-out ACL grant migrations.** Rationale: rollback becomes exact — `DELETE ... WHERE uuid IN (<literals>)` removes only the rows that file created and cannot touch Prudential's/Northwell's pre-existing role-3 grants. A rollback keyed on `(recordFieldId, roleId)` would have deleted theirs. uuid is unique per table per database, so one literal reused across tenants is valid. Rejected: the house habit of `UUID()` "so each client's rows are distinct" — it buys nothing here and costs the clean undo.
93
+ - **Reference `Core.RecordFields` ids as literals in `Client/` migrations.** Rationale: Core is on a separate cluster in production, so a `Core.*` lookup resolves only in non-prod; `Records`/`RecordFields` ids are team-assigned and verified identical across environments. Rejected: the cross-database lookup used by `2026-07-22c`.
94
+ - **Target Location lists all active locations minus the source, NOT filtered on `allowReceiving`.** Rationale: exactly 1 of NYCHH's 31 active locations has `allowReceiving = 1`, and it is the same location that has `allowSending = 1` — filtering would offer only the place you cannot transfer to. The flag is unmaintained for this tenant.
95
+ - **Source Location is resolved by flag (`allowSending = 1`), read-only from modal open** — not derived from row selection and not hardcoded. Rationale: another tenant, or a NYCHH rename, then needs no code change. The old selection-derived source lock was replaced by the PO lock.
96
+ - **The PO lock replaces the location lock** — all lines on one transfer must come from the same purchase order, per the live Claude Design export.
97
+
98
+ ## Blockers
99
+
100
+ - **HARD BLOCKER — no NYCHH PO line reports available stock, in sandbox or production.** `_Model_Nychh_PurchaseOrderItem::_qtyReceived` INNER JOINs `InventoryAdjustmentItems.itemFulfillmentItemId`, which is NULL on all 292 sandbox rows and all 317 production rows. Production PO lines with qty > 0: 0. The Create Transfer Order item picker cannot show a selectable line until either the NetSuite integration backfills that FK or the override's stock-in source changes. **This is an integration/data-owner decision, not a frontend fix.**
101
+ - **Demo trap:** the override is absent from `_beta` and `_sandbox-dev`, so those tiers fall back to the base item-receipt formula and **will show a working picker with real quantities**. A beta demo will look healthy while production is structurally empty. Do not read beta as evidence the feature works.
102
+ - Notes field has nowhere to persist (`TransferOrders` has no notes/description column).
103
+
104
+ ## Exact next step
105
+
106
+ > Take the blocker to Jason and the NetSuite integration owner with these two numbers: production `Client_Nychh` has 317 `InventoryAdjustmentItems` and **0** with `itemFulfillmentItemId` set, so `_Model_Nychh_PurchaseOrderItem::_qtyReceived` returns 0.00 for every PO line. Ask which of the two fixes they want — backfill `InventoryAdjustmentItems.itemFulfillmentItemId`, or change the override's stock-in source (base `_Model_Client_PurchaseOrderItem::_qtyReceived` uses `ItemReceiptItems` and returns 40.00 for `PurchaseOrderItems.id = 196`). No further frontend work on the item picker until that is answered.
107
+
108
+ ---
109
+ _Saved by /session-save on 2026-09-01_
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.725",
3
+ "version": "1.0.727",
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",