toga-ai 1.0.724 → 1.0.726
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/knowledge/1.0/apps/worker/workflows/netsuite-duplicate-items-dedup.md +18 -4
- package/knowledge/2.0/apps/_underscore/INDEX.md +1 -1
- package/knowledge/2.0/apps/_underscore/features/acl-permission-chain.md +130 -4
- package/knowledge/2.0/apps/api2/features/v2-rest-query-contract.md +59 -2
- package/knowledge/2.0/apps/dbchanges2/INDEX.md +1 -1
- package/knowledge/2.0/apps/dbchanges2/features/rerunnable-additive-inserts.md +54 -1
- package/knowledge/2.0/apps/dbchanges2/workflows/targeting-the-right-database.md +14 -1
- package/knowledge/2.0/apps/toga-blox/features/api-client.md +26 -1
- package/knowledge/2.0/apps/toga25-supply/INDEX.md +1 -1
- package/knowledge/2.0/apps/toga25-supply/architecture.md +8 -1
- package/knowledge/2.0/apps/toga25-supply/features/transfer-orders-page.md +143 -24
- package/knowledge/INDEX.md +1 -1
- package/knowledge/clients/compass-usa/workflows/catalog-fold-reversal.md +31 -4
- package/knowledge/clients/nychh/INDEX.md +2 -2
- package/knowledge/clients/nychh/features/transfer-order-inventory-quantities.md +182 -1
- package/knowledge/clients/nychh/profile.md +30 -2
- package/knowledge/clients/quad/INDEX.md +1 -0
- package/knowledge/clients/quad/profile.md +9 -1
- package/knowledge/clients/quad/workflows/catalog-fold-reversal.md +130 -0
- package/package.json +1 -1
|
@@ -111,13 +111,27 @@ duplicates** and must be deployed first — see
|
|
|
111
111
|
- **`UPDATE IGNORE ...; DELETE ... WHERE fk=dupe` permanently DELETES collision rows** and drops its
|
|
112
112
|
helper maps, so prod cannot self-reverse. A reversal needs a **pre-merge snapshot** restored as a
|
|
113
113
|
scratch DB — plan the backup before running any variant of this.
|
|
114
|
-
- **Blast radius of the 2026-08-31 `duplicate_items_merge_ALL_CLIENTS.sql
|
|
115
|
-
(Masonite, Ncci, Erau, Shrss, Wmchealth, Trividiahealth,
|
|
116
|
-
Nychh, Canon, Quad, Compass, Elite, Growrk, Spglobal,
|
|
117
|
-
|
|
114
|
+
- **Blast radius of the 2026-08-31 `duplicate_items_merge_ALL_CLIENTS.sql` (assessment complete
|
|
115
|
+
2026-09-01):** it ran on 17 clients (Masonite, Ncci, Erau, Shrss, Wmchealth, Trividiahealth,
|
|
116
|
+
Prudential, Miamidade, Browardsheriff, Nychh, Canon, Quad, Compass, Elite, Growrk, Spglobal,
|
|
117
|
+
Endeavorhealth). **Only 2 were damaged — Compass and Quad, both two-catalog TOGa Commerce
|
|
118
|
+
clients.** The other 15 needed no reversal: 13 single-catalog "Agilant"-only clients were a
|
|
119
|
+
legitimate within-catalog dedup, NYCHH mixed Etilize + NetSuite in one catalog (no fold
|
|
120
|
+
possible), and Prudential was cleared (single populated catalog; NULL `assetType` is its normal
|
|
121
|
+
state). Triage per client with `Catalogs` LEFT JOIN `Items` (empty catalogs still show) + the
|
|
122
|
+
pipeline signal (`c_etilizeProductId` = store vs `c_netsuiteInternalItemId` = Agilant); a client
|
|
123
|
+
is damaged only if it has a two-catalog store/procurement design AND catalog-1 was folded into
|
|
124
|
+
catalog-2 AND storefront/transmit depends on catalog-1. See the Compass
|
|
125
|
+
[catalog-fold reversal](../../../clients/compass-usa/workflows/catalog-fold-reversal.md) and
|
|
126
|
+
[Quad catalog-fold reversal](../../../clients/quad/workflows/catalog-fold-reversal.md).
|
|
118
127
|
|
|
119
128
|
## Change history
|
|
120
129
|
|
|
130
|
+
- 2026-09-01 — **Blast-radius assessment complete.** Of the 17 clients the merge ran on, only 2
|
|
131
|
+
were damaged — Compass and Quad (both two-catalog TOGa Commerce). The other 15 needed no
|
|
132
|
+
reversal (13 single-catalog "Agilant"-only = legitimate within-catalog dedup; NYCHH mixed
|
|
133
|
+
Etilize + NetSuite in one catalog; Prudential cleared — single populated catalog, NULL
|
|
134
|
+
assetType is normal). Added the durable triage method and linked the Quad reversal. (jcardinal)
|
|
121
135
|
- 2026-09-01 — **⚠ Corrected the success claim.** "0 duplicate part numbers" is NOT a clean-run proof
|
|
122
136
|
for a multi-catalog client. This merge is catalog-blind (groups on `UPPER(partNumber)` only), so for
|
|
123
137
|
Compass it folded 199 intentional catalog-1 store items into their Agilant canonical, broke MITS
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|-----|---------|-------|
|
|
5
5
|
| [Proposed — git-sourced base+overlay JSON authoring for the Surface layer](architecture/surface-authoring-proposal.md) | A **proposal / handoff recommendation** (not implemented) that the Surface layer's *authoring* model move off hand-authored SQL against the `SurfaceOverrides` E | _underscore/Model/Core/Surface.php, _underscore/Model/Client/SurfaceOverride.php |
|
|
6
6
|
| [_underscore Framework Architecture](architecture.md) | `_underscore` is the shared PHP backend framework for **all 2.0 applications**. | _underscore/_underscore.php, _underscore/Loader.php, _underscore/Framework.php, _underscore/Model.php, _underscore/Database.php, _underscore/Query.php, _underscore/Route.php, _underscore/Component.php |
|
|
7
|
-
| [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, dbchanges2/Core/2026-08-27a - TransferOrderUuidSearchableIdentifier.sql, _underscore/Model/Core/Page.php, _underscore/Model/Core/Surface.php, _underscore/Model/Client/TrackingNumber.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql, dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql, dbchanges2/Client/2026-08-29 - ItemReceiptTransferOrderIdFieldPermission.sql, dbchanges2/Client/2026-08-29b - ItemReceiptItemTransferOrderItemIdFieldPermission.sql |
|
|
7
|
+
| [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, dbchanges2/Core/2026-08-27a - TransferOrderUuidSearchableIdentifier.sql, _underscore/Model/Core/Page.php, _underscore/Model/Core/Surface.php, _underscore/Model/Client/TrackingNumber.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql, dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql, dbchanges2/Client/2026-08-29 - ItemReceiptTransferOrderIdFieldPermission.sql, dbchanges2/Client/2026-08-29b - ItemReceiptItemTransferOrderItemIdFieldPermission.sql, dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql |
|
|
8
8
|
| [Address Uniqueness Normalization (unit identifier + 5-digit ZIP comparison)](features/address-uniqueness-normalization.md) | When a business rule says *"only one X per physical address"*, comparing address rows field-for-field does **not** work: the same dwelling is spelled many diffe | _underscore/Model/Rate/Entitlement.php |
|
|
9
9
|
| [Address Validation (carrier waterfall + validateAddress scripted endpoint)](features/address-validation.md) | `_Model_Client_Address::validateAddress` verifies a US address against a **carrier waterfall (USPS → FedEx → UPS)** and returns a single canonical, carrier-norm | _underscore/Model/Client/Address.php, _underscore/Component/Library/Carriers/Usps/Usps.php |
|
|
10
10
|
| [_ApiRequest — JSON encode/decode & api-logging behavior](features/apirequest-json-content-type.md) | `_ApiRequest` is the 2.0 outbound HTTP client. | _underscore/ApiRequest.php |
|
|
@@ -6,8 +6,8 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
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)
|
|
131
|
-
`Client
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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/
|
|
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-
|
|
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.
|