toga-ai 1.0.581 → 1.0.583

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.
@@ -3,7 +3,7 @@
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
5
  | [API (api2 / TOGa API v2) Architecture](architecture.md) | `api2` is the backend powering the public **TOGa 2.0 API**. | api2/Controller/Index.php, api2/Component/Api/V2/V2.php, api2/Component/Api/Cxml/Cxml.php, api2/Component/Api/V2/Response/Response.php, api2/Config/ |
6
- | [API Payload Interceptors (metadata-registered prePost/postPut model hooks)](features/api-payload-interceptors.md) | A `_Model`'s `prePost()` / `postPost()` / `prePut()` / `postPut()` hooks are **not** called by the model. | api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Compass/PurchaseOrder.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/ServiceRequest.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderItem.php, dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql, dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql |
6
+ | [API Payload Interceptors (metadata-registered prePost/postPut model hooks)](features/api-payload-interceptors.md) | A `_Model`'s `prePost()` / `postPost()` / `prePut()` / `postPut()` hooks are **not** called by the model. | api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Compass/PurchaseOrder.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/ServiceRequest.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderItem.php, dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql, dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql, dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql, dbchanges2/Client_Compass/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql, dbchanges2/Client_CompassCanada/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql |
7
7
  | [Multi-Client (Cross-Client) Data Retrieval](features/cross-client-data-retrieval.md) | A single authenticated V2 GET listing can return records across **many** clients (designed for 1000+) that the caller is entitled to, honoring **each target cli | api2/Component/Api/CrossClient/CrossClient.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Cache/Table.php, _underscore/Model/Cache/Tables/Client.php, _underscore/Model/Core/Record.php, worker2/Worker/Platform/Cache.php, worker2/Controller/Index.php, worker2/_.php, dbchanges2/Cache/2026-06-30a - MultiClientCacheTables.sql, dbchanges2/Core/2026-06-30b - CacheClusterRegistrationAndRecordTtl.sql, dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql |
8
8
  | [Encrypted-User-UUID Auth Handoff (/auth/encrypted-user-uuid)](features/encrypted-user-uuid-auth-handoff.md) | `POST /auth/encrypted-user-uuid` is the intended **cross-client / SSO-handoff identity mechanism**: given an encrypted `{client, user}` UUID pair, it mints a fr | api2/Component/Api/CrossClient/CrossClient.php |
9
9
  | [ENVIRONMENT (not the EB environment name) decides the _underscore branch and Config file](features/environment-variable-drives-underscore-branch.md) | An api2 Elastic Beanstalk instance decides **which `_underscore` branch it clones** and **which `Config/<env>.ini` it loads** from the EB environment property * | api2/.ebextensions/git.php, api2/.ebextensions/php_include_underscore.config, api2/.ebextensions/git.sandbox-dev.json, api2/Config/beta.ini, api2/Config/sandbox-dev.ini, api2/Component/Api/V2/V2.php |
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-13
9
+ updated: 2026-08-14
10
10
  owners: ["mhammontree", "dfranks", "bala", "snaredla"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -19,8 +19,12 @@ files:
19
19
  - _underscore/Model/Compass/SalesOrderItem.php
20
20
  - dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql
21
21
  - dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql
22
+ - dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql
23
+ - dbchanges2/Client_Compass/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql
24
+ - dbchanges2/Client_CompassCanada/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql
22
25
  related:
23
26
  - ./record-scripts.md
27
+ - ../../toga2-commerce/features/inactive-item-purchase-gating.md
24
28
  - ../architecture.md
25
29
  - ../../_underscore/features/acl-permission-chain.md
26
30
  - ../../_underscore/features/recursive-item-fulfillments.md
@@ -137,6 +141,37 @@ Two consequences worth internalising:
137
141
  silently does nothing — the same class of failure as the `internalApiRequest` `data`-envelope
138
142
  trap below.
139
143
 
144
+ ### A PRE interceptor on a PARENT record also fires on the NESTED child POST — with the CHILD as `$payload`
145
+
146
+ `POST /v2/sales-orders/{uuid}/sales-order-items` resolves **`SalesOrders` as the first route pair**
147
+ (`api2/Component/Api/V2/V2.php:3185-3214`), so a `(recordId 14, PRE, POST)` row **does** fire — and
148
+ the payload handed to it is the **raw decoded request body**, which on that route is a **single
149
+ line-item object with no `salesOrderItems` wrapper**.
150
+
151
+ **Consequence: a collector written as `foreach ($payload->salesOrderItems as …)` silently no-ops on
152
+ exactly the requests you most wanted to guard.** A parent-record PRE hook must handle both shapes:
153
+
154
+ ```php
155
+ $hasSalesOrderItems = (is_object($node) && isset($node->salesOrderItems) && is_array($node->salesOrderItems));
156
+ $lines = $hasSalesOrderItems ? $node->salesOrderItems : [$node];
157
+ ```
158
+
159
+ …and, since a payload may arrive as one node or a list, wrap that in
160
+ `is_array($payload) ? $payload : [$payload]` first.
161
+
162
+ **The flat sibling route is a DIFFERENT record and is not covered.**
163
+ `PUT /v2/sales-order-items/{uuid}` is **records id 15**, so a record-14 interceptor never sees it.
164
+ Whether that gap matters is a design decision, not an oversight to close reflexively — see
165
+ [inactive-item purchase gating](../../toga2-commerce/features/inactive-item-purchase-gating.md),
166
+ where a record-15 `prePut` was **deliberately not** registered because it would have blocked
167
+ quantity edits on pre-existing lines.
168
+
169
+ **Do not assume the client sends nested children.** TOGa Commerce never does: its header sync
170
+ `delete`s `salesOrderItems` from the edit `PUT` payload and writes lines **one at a time** — new
171
+ lines through the nested `POST` above, existing lines through the flat record-15 `PUT`. So the
172
+ route pair that actually carries your data may not be the one the API docs suggest. See
173
+ [order-submit sync sequencing](../../toga2-commerce/features/order-submit-sync-sequencing.md).
174
+
140
175
  ## The dispatch is UNGUARDED — a row naming a method that does not exist hard-fatals the endpoint
141
176
 
142
177
  The derived name (step 2 above) is **pure convention with nothing validating it**, and there is
@@ -354,6 +389,22 @@ and the failing environment**. It is a small table, and the drift is usually exa
354
389
  change that switches on a *code* path; if the PHP defining the method is not confirmed deployed to
355
390
  that environment, the row takes the endpoint down. Insert inactive, verify the deploy, then flip
356
391
  `isActive = 1` — and remember every environment activates independently.
392
+ - **⚠ Guard the INSERT with `NOT EXISTS` on `(apiId IS NULL, recordId, prePostProcessing,
393
+ httpMethod)` — and know what that costs you.** The real key is **four** columns and api-scoped
394
+ rows legitimately coexist with an `apiId NULL` row, so a two- or three-column guard would suppress
395
+ a row it should have inserted. The trade-off: **the guard also makes a later `isActive` flip in
396
+ the same file a no-op**. An environment that already ran the migration at `isActive = 0` is *not*
397
+ activated by re-running the edited file at `isActive = 1` — that environment needs a manual
398
+ `UPDATE`. Worked example (both Compass tenants, 2026-08-14):
399
+ `dbchanges2/Client_Compass/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql`.
400
+ - **⚠ Drift also runs the other way: a row that PRODUCTION already removed can still be live in
401
+ non-prod.** The `_Model_Compass_Usa_SalesOrderItem::postPost()` outage below was fixed in
402
+ production by `dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql`,
403
+ but that migration had **never been run on dev-sandbox/beta**, so the same row was still there on
404
+ 2026-08-14 and every Compass order placed on beta returned `500 EO-1`
405
+ (`Logs.Issue` id 11: *"Call to undefined method `_Model_Compass_Usa_SalesOrderItem::postPost()`"*).
406
+ Deleting the row fixed it. **When a write endpoint 500s only in non-prod, look for an EXTRA row a
407
+ cleanup migration removed upstream — not only for a missing one.**
357
408
  - **⚠ A post interceptor's `$payload` is the record, not a route-keyed envelope** — and it is
358
409
  by-reference, so pull the record into a local instead of reassigning `$payload`.
359
410
  - **⚠ Validate that a payload ARRAY's elements are objects before touching them — `property_exists()`
@@ -397,6 +448,23 @@ and the failing environment**. It is a small table, and the drift is usually exa
397
448
 
398
449
  ## Change history
399
450
 
451
+ - 2026-08-14 — TRUE-80489, from building the Compass inactive-standalone-item order guard.
452
+ (1) **A PRE interceptor on a PARENT record fires on the nested child POST, with the CHILD as the
453
+ payload.** `POST /sales-orders/{uuid}/sales-order-items` resolves `SalesOrders` as the
454
+ first route pair (`V2.php:3185-3214`) and hands the hook the **raw decoded body** — a single
455
+ line-item object with **no `salesOrderItems` wrapper** — so a collector that only reads
456
+ `$payload->salesOrderItems` silently no-ops on exactly those requests; handle both shapes. The
457
+ flat sibling `PUT /sales-order-items/{uuid}` is **records id 15** and is not covered by a
458
+ record-14 row at all. Also recorded that **TOGa Commerce never sends nested lines**: it `delete`s
459
+ `salesOrderItems` from the header `PUT` and writes each line individually. (2) Added the
460
+ **`NOT EXISTS` idempotency guard** keyed on the real four-column key
461
+ (`apiId IS NULL + recordId + prePostProcessing + httpMethod`, because api-scoped rows can
462
+ legitimately coexist) — and the catch that the same guard makes a later `isActive 0 → 1` edit of
463
+ that file a **no-op** in any environment that already ran it. (3) Recorded the **inverse drift
464
+ case**: the broken `_Model_Compass_Usa_SalesOrderItem::postPost()` row that production removed via
465
+ the 2026-08-06 cleanup migration was **still live on dev-sandbox/beta**, 500-ing every Compass
466
+ order on beta (`Logs.Issue` id 11) until it was deleted — so a non-prod-only write failure can be
467
+ an **extra** row, not a missing one. (bala)
400
468
  - 2026-08-13 — TRUE-80412, from building the Compass zero-line-item PO guard. (1) Recorded the
401
469
  lookup predicate — **`apiId IS NULL OR apiId = <caller's api>`** — and the verified row set for
402
470
  `recordId 17` (`purchase-orders`): **no `Core` row at all**, and a `PRE` + `POST` pair in **each**
@@ -89,6 +89,30 @@ response that walks that collection can 500. Two defences, both cheap:
89
89
  The fragility itself is **unfixed** — any depth ≥ 2 response is exposed. Lowering depth removes
90
90
  *your* exposure, not the defect.
91
91
 
92
+ ### ⚠ A `where` clause on a single-uuid READ is silently IGNORED
93
+
94
+ **`GET /v2/items/{uuid}` does not filter.** The by-uuid READ path loads the record through
95
+ `locateRecord()`, which resolves by uuid and **never reads `httpOptions['where']`**. The parameter
96
+ is accepted, produces no error, and has no effect — so a caller that adds
97
+ `where=(Items.isActive:eq:1)` to a single-uuid GET gets the record back **regardless of the
98
+ filter** and believes it has been gated.
99
+
100
+ **Use the LIST route instead**, and normalize the result:
101
+
102
+ ```
103
+ GET /v2/items?where=(Items.uuid:eq:<uuid>,AND,Items.isActive:eq:1)&recordsPerPage=1
104
+ ```
105
+
106
+ The list route returns `data.items` as an **array**; the single-uuid route returns a single
107
+ **object**. Callers that switch routes must normalize `data.items[0] ?? null` back to the shape the
108
+ rest of the code expects — a `null` there is then a truthful *"missing or filtered out."* Worked
109
+ example: [TOGa Commerce inactive-item purchase
110
+ gating](../../toga2-commerce/features/inactive-item-purchase-gating.md).
111
+
112
+ This is the mirror image of the LIST → READ flip below: there, an unrecognized param moves you onto
113
+ the READ path and you get a loud 404; here, a **recognized** param is simply inert on that path and
114
+ you get a quiet wrong answer.
115
+
92
116
  ### CRITICAL — an unrecognized param silently flips LIST → READ
93
117
 
94
118
  Any query param the engine does not recognize switches the request onto a **different code
@@ -161,6 +185,10 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
161
185
  you never requested is expected, not a red herring; (b) **every** route whose traversal reaches that
162
186
  record fails identically, so narrowing `fields=` cannot work around it. The fix is schema-side —
163
187
  see [client schema-drift audit](../../dbchanges2/workflows/client-schema-drift-audit.md).
188
+ - **⚠ `where` does nothing on `/{route}/{uuid}`.** The by-uuid READ loads via `locateRecord()` and
189
+ never reads `httpOptions['where']`. There is no error — you get the record back unfiltered. If a
190
+ filter must apply, use the LIST route with `Items.uuid:eq:<uuid>` in the `where` and normalize
191
+ `data.items[0]`.
164
192
  - **A 404 on a route you know exists ⇒ suspect an unrecognized query param** (LIST → READ flip)
165
193
  before suspecting the route.
166
194
  - **A 500 (`EO-1`) on a filter ⇒ suspect a `where` reference to an unjoined table.**
@@ -169,6 +197,13 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
169
197
  invisible; re-request with `fields=` to force `EZ-2` and see the denied list.
170
198
 
171
199
  ## Change history
200
+ - 2026-08-14 — Recorded that **a `where` clause on a single-uuid READ is silently ignored**:
201
+ `GET /v2/items/{uuid}` resolves through `locateRecord()`, which never reads
202
+ `httpOptions['where']`, so the filter is accepted, errors nothing, and returns the record
203
+ regardless. The LIST form (`GET /v2/items?where=(Items.uuid:eq:…,AND,Items.isActive:eq:1)`) is the
204
+ only shape where the filter applies, and switching routes changes the payload from a single object
205
+ to an array that the caller must normalize (`data.items[0] ?? null`). Found while gating inactive
206
+ items out of the TOGa Commerce item page. (bala)
172
207
  - 2026-08-11 — **Corrected the sort contract and documented the `DISTINCT` LIST select.** The old
173
208
  "do not send `+`" was wrong as an absolute: `V2.php` ~L3673–3682 explicitly handles `+` as ASC.
174
209
  The real caveat is encoding — the query string is parsed without urldecoding, so a
@@ -3,8 +3,8 @@
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
- | [Surface Layer Schema (UI presentation/config tables)](features/surface-layer-schema.md) | The persistent schema for the platform-wide **Surface** UI presentation/configuration layer (see the `_underscore` [surface-resolver](../../_underscore/features | dbchanges2/Client/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 |
6
+ | [Surface Layer Schema (UI presentation/config tables)](features/surface-layer-schema.md) | The persistent schema for the platform-wide **Surface** UI presentation/configuration layer (see the `_underscore` [surface-resolver](../../_underscore/features | dbchanges2/Client/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 |
7
7
  | [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/ |
8
8
  | [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 |
9
9
  | [Local vs prod MySQL config parity — why “it passed locally” is not evidence](workflows/local-vs-prod-mysql-config-parity.md) | Several migration failures that look like "prod-only bugs" are actually **per-machine MySQL server-configuration differences**. | |
10
- | [Repairing non-prod metadata drift (works in prod, broken in beta/dev-sandbox)](workflows/nonprod-metadata-drift-repair.md) | Almost all 2.0 platform behavior is **metadata** — `Core.Records`/`RecordFields`, `Core.RecordScripts`, `Core.ApiPayloadInterceptors`, and per-client `Acl*` row | dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql, dbchanges2/Core/2026-07-16a - TrackingNumberSignatureTypeRecordField.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql, api2/Config/beta.ini |
10
+ | [Repairing non-prod metadata drift (works in prod, broken in beta/dev-sandbox)](workflows/nonprod-metadata-drift-repair.md) | Almost all 2.0 platform behavior is **metadata** — `Core.Records`/`RecordFields`, `Core.RecordScripts`, `Core.ApiPayloadInterceptors`, and per-client `Acl*` row | dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql, dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql, dbchanges2/Core/2026-07-16a - TrackingNumberSignatureTypeRecordField.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql, api2/Config/beta.ini |
@@ -6,7 +6,7 @@ project: Database Changes
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-23
9
+ updated: 2026-08-14
10
10
  owners: [jcardinal, apeterson]
11
11
  files:
12
12
  - dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql
@@ -66,6 +66,8 @@ files:
66
66
  - dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql
67
67
  - dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql
68
68
  - dbchanges2/Core/2026-07-23a - PoNumberDetailFieldValueKey.sql
69
+ - dbchanges2/Core/2026-07-29a - SalesOrderApprovalDetailsSurfaceSeed.sql
70
+ - dbchanges2/Core/2026-08-05b - ApprovalDetailsShippingMethodConcatCharge.sql
69
71
  related:
70
72
  - ../../_underscore/features/surface-resolver.md
71
73
  ---
@@ -512,6 +514,19 @@ rule resumes.
512
514
  widening for the rule attributes); and for the `/surfaces/meta` endpoint to resolve at all, the ACL
513
515
  dispatch grants (`Client/2026-06-25c`, `2026-06-29c`, `2026-06-30a`). Skipping the table/ENUM
514
516
  bring-up makes the `2026-07-17a/b` override inserts fail or silently coerce.
517
+ - **A `SurfaceElements` seed guard keyed on `config.valueKey` is NOT durably idempotent.** If a
518
+ later migration mutates that same valueKey, the guard goes blind and a re-run inserts a
519
+ **duplicate** element (same sortOrder/label/valueKey, different uuid/id) — the field then renders
520
+ twice. Proven case: `Core/2026-07-29a - SalesOrderApprovalDetailsSurfaceSeed.sql` inserts the
521
+ sales-order-approval-details "Shipping Method" element guarded by
522
+ `NOT EXISTS (... config.valueKey = 'shippingMethod.display')`; `Core/2026-08-05b -
523
+ ApprovalDetailsShippingMethodConcatCharge.sql` later UPDATEs that valueKey to `shippingMethod.name`
524
+ (+ `isConcatenated` charge). After that mutation, re-running `07-29a` sees no `.display` element and
525
+ inserts a **second** shipping-method element; re-running `08-05b` then converts the dup to `.name`
526
+ too. **Rule:** key idempotency guards on a **stable** identifier (`labelMessageId` / the message
527
+ key) that display-tweak migrations don't touch — never on `config.valueKey`. (Cleanup here was a
528
+ one-off manual DELETE of the higher-id dup on the developer's local DB — local operator error from
529
+ running seeds twice, deliberately not committed to dbchanges2.)
515
530
  - **"Enter PO Details" (element 32) has a DUAL visibility gate.** Its rendering is gated by BOTH the
516
531
  element-level `isVisible` flag AND a `config`-embedded `"isVisible":false` (migrated from Quad's
517
532
  legacy field JSON, alongside `isEnabled`/`isComplete`). Flipping the element-level `IS_VISIBLE=1`
@@ -521,6 +536,15 @@ rule resumes.
521
536
  override is added). **Open follow-up.**
522
537
 
523
538
  ## Change history
539
+ - 2026-08-14 — Recorded a durable idempotency-guard gotcha (below): a `SurfaceElements` seed
540
+ guard keyed on `config.valueKey` is defeated by any later migration that mutates that same
541
+ valueKey, causing duplicate element inserts on re-run. Concrete case: `Core/2026-07-29a`
542
+ guards the approval-details "Shipping Method" element on `config.valueKey='shippingMethod.display'`,
543
+ and `Core/2026-08-05b` later UPDATEs it to `shippingMethod.name`; re-running `07-29a` then no
544
+ longer sees a `.display` element and inserts a second one. Cleanup was a one-off manual DELETE on
545
+ the developer's local DB (local operator error from re-running seeds), deliberately **not** added
546
+ to dbchanges2. Rule: guard seed elements on a **stable** identifier (labelMessageId / message key),
547
+ not on `config.valueKey` which display-tweak migrations commonly change. (apeterson)
524
548
  - 2026-07-31 — **Closed the "audit the cross-DB surface seeds" open item** (TRUE-80487): swept all
525
549
  505 non-`HISTORIC` `.sql` files and found **24** cross-tenant references (13 in `Client/`,
526
550
  including the `BLANK_CLIENT_DATABASE` new-client template; 3 in `Client_Rate/`; 2 in `Core/`
@@ -6,10 +6,11 @@ project: Database Changes
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-08-10
9
+ updated: 2026-08-14
10
10
  owners: ["mhammontree", "bala"]
11
11
  files:
12
12
  - dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql
13
+ - dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql
13
14
  - dbchanges2/Core/2026-07-16a - TrackingNumberSignatureTypeRecordField.sql
14
15
  - dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql
15
16
  - api2/Config/beta.ini
@@ -47,6 +48,16 @@ exercise TOGa Supply Fulfill & Ship (TRUE-80494).
47
48
  (dev-sandbox was missing `(recordId 28, PRE, POST)`, so `prePost` never defaulted
48
49
  `itemFulfillmentStageId` → `EV-10 Column 'itemFulfillmentStageId' cannot be null`). Apply the
49
50
  existing migration rather than writing a new one.
51
+ **⚠ Diff in BOTH directions — the drift is just as often an EXTRA row.** A *cleanup* migration
52
+ that removed a bad row in production is exactly as easy to forget in non-prod as an additive one.
53
+ Worked example (2026-08-14): `Client_Compass/2026-08-06 -
54
+ RemoveBrokenSalesOrderItemPostPostInterceptor.sql` had never been run on dev-sandbox, so the row
55
+ deriving `postPost` on `_Model_Compass_Usa_SalesOrderItem` (which does not define it) was still
56
+ active there and **every Compass order placed on beta returned `500 EO-1`** — *"Call to undefined
57
+ method"*, `Logs.Issue` id 11 — while production was fine. Symptom-wise this is indistinguishable
58
+ from a code regression on the branch under test, which is exactly why it burns time: the branch
59
+ gets blamed. Deleting the row fixed it. **A "works in prod, broken in beta" report should trigger
60
+ a full-table diff, not a re-read of the diff you just wrote.**
50
61
  3. **For a field that reads back empty/blank, check `AclFieldPermissions` before the selector.** An
51
62
  ungranted field is **silently omitted** from the GET body (HTTP 200, no message). `Measures.measureType`
52
63
  had zero grant rows in dev-sandbox `Client_Growrk` → omitted from `GET /v2/measures` → the frontend's
@@ -145,6 +156,13 @@ blocked by the id-divergence hazard in item 2 above, which is why both go to jca
145
156
 
146
157
  ## Change history
147
158
 
159
+ - 2026-08-14 — Recorded that metadata drift runs in **both** directions: a cleanup migration that
160
+ *removed* a row in production is as easily skipped in non-prod as an additive one. Evidence —
161
+ `Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql` had never run on
162
+ dev-sandbox, so the broken `postPost` interceptor row was still active there and **every Compass
163
+ order placed on beta 500'd (`EO-1`, `Logs.Issue` id 11)** while production was healthy; the
164
+ failure looked exactly like a regression in the feature branch being tested. Step 2 now says to
165
+ diff the table wholesale in both directions. (bala)
148
166
  - 2026-08-10 — Added a disambiguation pointer to the new
149
167
  [client schema-drift audit](./client-schema-drift-audit.md) for the **inverse** case (production or
150
168
  a single client's DB is the outlier, e.g. a partially applied `_modules/` migration). No change to
@@ -6,10 +6,12 @@
6
6
  | [Cart Bundle Submission & the bundleUuid Identity Contract](features/cart-bundle-submission-and-identity.md) | How cart **bundles** (kits) are turned into `SalesOrderItems` when a cart is submitted or an existing order is edited, and the **identity-field contract** every | src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts, src/utils/formatSalesOrderBundlesFromApi.ts, src/stores/useCartStoreZu.ts, src/pages/OrderDetails/helpers/formatSalesOrderDataFromLocalStorage.ts, src/pages/OrderDetails/view/components/OrderItems.tsx |
7
7
  | [Cart Notification Emails — duplicate prevention](features/cart-notification-emails.md) | On the cart "Notifications" section a user can add CC email addresses to an order. | src/pages/Cart/CartPage.tsx, src/pages/Cart/view/cartForm/CartForm.tsx, src/stores/useEmailOptionsStore.ts, src/stores/useCartSalesQuoteZu.ts, src/pages/Cart/viewModel/FIELDS/*/*/*/CARTPAGE.ts |
8
8
  | [Cart Page — config-driven form architecture (current state + planned refactor)](features/cart-page-config-architecture.md) | The Cart page (`src/pages/Cart/`) is the most config-heavy page in `toga2-commerce`. | src/pages/Cart/CartPage.tsx, src/pages/Cart/view/cartForm/CartForm.tsx, src/pages/Cart/view/cartForm/CartFormSection.tsx, src/pages/Cart/view/cartForm/CartFormRenderer.tsx, src/pages/Cart/view/EditCart.tsx, src/pages/Cart/view/EditOrder.tsx, src/pages/Cart/viewModel/useEditOrderOrEditCartViewModel.ts, src/pages/Cart/viewModel/FIELDS/*/*/*/CARTPAGE.ts, src/hooks/useAssignClientFields.ts |
9
+ | [Catalog cache freshness — the 24h persisted query cache, and how to opt a query out of it](features/catalog-cache-freshness.md) | TOGa Commerce runs a **single `QueryClient` with a 24-hour default `staleTime`**, and persists it to **`localStorage["commerce"]`** through `PersistQueryClientP | toga2-commerce/src/App.tsx, toga2-commerce/src/contexts/AuthContext.tsx, toga2-commerce/src/pages/ItemsView/viewModel/useItemDetailsViewModel.ts |
9
10
  | [Category Tile Order (AssortmentItems.sortOrder) — merchandising a storefront category](features/category-tile-sort-order.md) | **"Move item X to the front of category Y" is a DATA change, not a code change.** The order of item tiles on a storefront category page is driven by exactly one | src/pages/Filter/api/FilterApi.ts, src/pages/Filter/viewModel/useFilterViewModel.ts, api2/Component/Api/V2/V2.php, toga2-supply/src/pages/Items/api/itemsApi.ts |
10
11
  | [Client Fields — per-tenant / language / role content & config](features/client-fields.md) | Almost no user-facing text, field layout, or page config is hard-coded in `toga2-commerce`. | src/pages/Account/view/MySettingsView.tsx, src/contexts/helpers/getLoginSettings.ts, src/pages/Account/viewModel/useAccountViewModel.ts, src/fieldsConfig/index.ts, src/fieldsConfig/getClientLoginFields.ts, src/fieldsConfig/clientFields/COMPASS.json, src/fieldsConfig/clientFields/COMPASSCANADA.json, src/fieldsConfig/clientFields/QUAD.json, src/pages/Cart/api/CartApi.ts, src/hooks/useAuthenticationFlow.ts, src/contexts/AuthContext.tsx, src/pages/Login/viewModel/useLoginPageViewModel.ts, src/hooks/useAssignClientFields.ts, src/hooks/useDynamicConditionalFieldOptions.ts, src/stores/useFieldsStore.ts, src/components/BaseDetailField/BaseDetailField.tsx, src/components/NavIcons/NavIconItem.tsx, src/components/Submenus/AlertSubmenu.tsx, src/components/Submenus/types.ts, src/pages/Account/AccountPage.tsx, src/pages/Account/view/MyOrdersView.tsx, src/pages/GetSupport/GetSupportPage.tsx, src/pages/GetSupport/viewModel/useGetSupportViewModel.ts, src/queries/queries.ts, src/App.tsx, src/pages/Filter/FilterPage.tsx, src/pages/Filter/viewModel/FIELDS/COMPASS/ENGLISH/USER/FILTERPAGEFIELDS.json |
11
12
  | [Config-Driven Expedited Shipping Gating (Cart)](features/expedited-shipping-gating.md) | On the toga2-commerce **Cart** page, expedited shipping options (**"2nd Day EOB"** and **"Next Day Air"**) are only offered in the *Shipping Method* dropdown wh | toga2-commerce/src/pages/Cart/helpers/shippingOptionGates.ts, toga2-commerce/src/pages/Cart/viewModel/FIELDS/shared/shippingOptionGates.ts, toga2-commerce/src/pages/Cart/view/cartForm/CartForm.tsx, toga2-commerce/src/pages/Cart/CartPage.tsx |
12
13
  | [Filter / Search-Results Page & the Two Search Entry Points](features/filter-search-results-page.md) | The storefront has **two distinct search entry points that render the same card component through completely different code paths and different FIELDS files**. | src/pages/Filter/FilterPage.tsx, src/pages/Filter/viewModel/useFilterViewModel.ts, src/pages/Filter/viewModel/FIELDS/COMPASS/ENGLISH/USER/FILTERPAGEFIELDS.json, src/pages/Filter/viewModel/FIELDS/COMPASSCANADA/FRENCH/USER/FILTERPAGEFIELDS.json, src/pages/Filter/viewModel/FIELDS/QUAD/ENGLISH/BUYER/FILTERPAGEFIELDS.json, src/components/Header/Header.tsx, src/pages/Home/view/components/BundlesSection.tsx, src/pages/Home/viewModel/FIELDS/COMPASS/ENGLISH/USER/HOMEPAGEFIELDS.json, src/components/Cards/BundleViewCard.tsx, src/utils/renderBadge.tsx, src/hooks/useAssignClientFields.ts |
14
+ | [Inactive-item purchase gating (standalone lines only — kits are exempt by design)](features/inactive-item-purchase-gating.md) | An item with **`Items.isActive = 0`** must not be viewable, addable to a cart, or orderable **as a standalone line** on the storefront — but the **same flag is | toga2-commerce/src/utils/checkIsItemPurchasable.ts, toga2-commerce/src/api/fetchItemsActiveStatus.ts, toga2-commerce/src/hooks/useCartReconciliation.ts, toga2-commerce/src/stores/useCartStoreZu.ts, toga2-commerce/src/pages/ItemsView/api/ItemsApi.ts, toga2-commerce/src/pages/ItemsView/viewModel/useItemDetailsViewModel.ts, toga2-commerce/src/pages/ItemsView/ItemViewPage.tsx, toga2-commerce/src/components/AuthLayout/AuthLayout.tsx, toga2-commerce/src/pages/Cart/CartPage.tsx, toga2-commerce/src/pages/Cart/view/cartTable/CartTableBundleItem.tsx, toga2-commerce/src/api/syncSalesOrderFromApiToLocalStorage.ts, _underscore/Model/Compass/SalesOrder.php, dbchanges2/Client_Compass/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql, dbchanges2/Client_CompassCanada/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql |
13
15
  | [Multi-Tenant Resolution & Theming](features/multi-tenant-theming.md) | `toga2-commerce` serves multiple clients from one codebase. | src/themeConfig/themes.json, src/themeConfig/ThemeContext.tsx, src/themeConfig/types.ts, src/components/ThemeSwitcher/ThemeSwitcher.tsx, src/components/AuthLayout/AuthLayout.tsx, src/api/axiosInstance.ts, src/contexts/AuthContext.tsx, tailwind.config.js |
14
16
  | [Order-submit sync sequencing (useSubmitOrder) — why these calls must not run in parallel](features/order-submit-sync-sequencing.md) | Submitting an order from the cart fires **two independent sync routines** — one for the sales-order header (`syncSalesOrderData`) and one for the line items (`s | toga2-commerce/src/pages/OrderDetails/hooks/useSubmitOrder.ts, toga2-commerce/src/api/syncSalesOrdersDataFromLocalStorageCartToApi.ts, toga2-commerce/src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts |
15
17
  | [AWS Amplify Build & Deploy (non-prod environments)](workflows/amplify-build-and-deploy.md) | How `toga2-commerce` (React + Vite, "commerce2-react") builds and deploys on **AWS Amplify**. | toga2-commerce/amplify.yml, toga2-commerce/.gitattributes, toga2-commerce/package.json, toga2-commerce/.github/workflows/sync-stage-environments.yml |
@@ -0,0 +1,112 @@
1
+ ---
2
+ title: Catalog cache freshness — the 24h persisted query cache, and how to opt a query out of it
3
+ framework: "2.0"
4
+ repo: toga2-commerce
5
+ project: TOGa Commerce
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-14
10
+ owners: ["bala"]
11
+ files:
12
+ - toga2-commerce/src/App.tsx
13
+ - toga2-commerce/src/contexts/AuthContext.tsx
14
+ - toga2-commerce/src/pages/ItemsView/viewModel/useItemDetailsViewModel.ts
15
+ related:
16
+ - ./inactive-item-purchase-gating.md
17
+ - ../architecture.md
18
+ - ../../toga25-supply/features/force-logout-on-deployment.md
19
+ ---
20
+
21
+ ## Summary
22
+
23
+ TOGa Commerce runs a **single `QueryClient` with a 24-hour default `staleTime`**, and persists it
24
+ to **`localStorage["commerce"]`** through `PersistQueryClientProvider`. That combination means the
25
+ storefront can serve **catalogue state that is up to 24 hours out of date, across full page
26
+ reloads** — and it does so **in both directions**: after an item is switched off, a user who had
27
+ already viewed it keeps seeing the live product page with a working Add to Cart; after it is
28
+ switched back on, it stays hidden.
29
+
30
+ Any query whose answer **gates an action** (can this be bought? is this still available?) must be
31
+ opted out of *both* mechanisms. Opting out of one is not enough.
32
+
33
+ ## How it works
34
+
35
+ ### The default: cached and persisted
36
+
37
+ `src/App.tsx` creates the client with `staleTime: 1000 * 60 * 60 * 24` and a
38
+ `createSyncStoragePersister` on `localStorage` key `"commerce"`. Everything inherits that unless it
39
+ overrides it — including the `["clientFields", …]` query that carries the per-tenant `FIELDS`
40
+ content, which is why copy/config deploys also look stale for returning users (see the
41
+ [architecture gotchas](../architecture.md#gotchas)).
42
+
43
+ ### The platform's cache-bust: `META_LAST_REFRESH_DATETIME`
44
+
45
+ The **only** mechanism that clears the persisted cache for everyone is the Core parameter
46
+ **`META_LAST_REFRESH_DATETIME`** (`Core.Parameters` id 32). `src/contexts/AuthContext.tsx` polls it
47
+ with `staleTime: 0` **on every route change and on window focus**; when the value differs from the
48
+ one captured at login it calls `logout()` and forces a full reload, which clears the saved cache.
49
+
50
+ > **⚠ Bumping that parameter logs out EVERY user on that environment.** It is a deployment-grade
51
+ > lever, not a way to refresh one catalogue change. The sibling implementation in TOGa 2.5 Supply
52
+ > is documented in
53
+ > [force logout on deployment](../../toga25-supply/features/force-logout-on-deployment.md).
54
+
55
+ ### Opting a single query out — `staleTime: 0` + `gcTime: 0` is NOT sufficient on its own
56
+
57
+ The item-detail queries (`fetchItemDetails`, `fetchItemDetailsViaBundle`) are set to
58
+ `staleTime: 0` **and** `gcTime: 0`, **and** excluded from persistence in `App.tsx`:
59
+
60
+ ```ts
61
+ const NON_PERSISTED_QUERY_KEYS: string[] = ["fetchItemDetails", "fetchItemDetailsViaBundle"];
62
+
63
+ const shouldDehydrateQuery = (query) => {
64
+ const queryName = query.queryKey?.[0];
65
+ if (typeof queryName === "string" && NON_PERSISTED_QUERY_KEYS.includes(queryName)) return false;
66
+ return query.state.status === "success";
67
+ };
68
+ // … persistOptions: { dehydrateOptions: { shouldDehydrateQuery } }
69
+ ```
70
+
71
+ **Why the exclusion is required:** `gcTime: 0` only evicts a query once it becomes **inactive**. An
72
+ **active** query — exactly the state the item page is in while the user is looking at it — is still
73
+ dehydrated and written to `localStorage`, and is rehydrated on the next load. So `gcTime: 0` alone
74
+ leaves the stale answer on disk.
75
+
76
+ The default branch (`query.state.status === "success"`) preserves TanStack's normal behaviour for
77
+ every other query, so this is an allowlist-shaped opt-out with no app-wide side effects.
78
+
79
+ **Listings were deliberately left on the 24h cache.** Only the item-detail path gates a purchase
80
+ decision; making every listing uncached would trade a real performance characteristic for no
81
+ correctness gain.
82
+
83
+ ## Gotchas / known issues
84
+
85
+ - **⚠ Never verify a catalogue or FIELDS change on a warm browser.** Clear
86
+ `localStorage.removeItem('commerce')` or log out first. A "it didn't deploy" report is usually a
87
+ rehydrated cache.
88
+ - **⚠ `gcTime: 0` does not stop persistence.** Active queries are still dehydrated. Exclude the key
89
+ via `dehydrateOptions.shouldDehydrateQuery` as well.
90
+ - **⚠ `META_LAST_REFRESH_DATETIME` is not a targeted invalidation** — it force-logs-out every user
91
+ on the environment. Use per-query opt-out or `queryClient.invalidateQueries` for anything
92
+ narrower.
93
+ - **A `persistOptions.buster` keyed on the build version** would fix stale FIELDS at deploy time but
94
+ invalidates every persisted query app-wide — still an **open team decision, not implemented**
95
+ (carried over from the architecture doc).
96
+ - **`AuthLayout` never remounts**, so `refetchOnMount` fires once per session for queries it owns —
97
+ a route-change refresh has to be wired explicitly. That interacts with everything above: a query
98
+ can be both stale *and* never refetched during a session.
99
+
100
+ ## Change history
101
+
102
+ - 2026-08-14 — Created while building the
103
+ [inactive-item purchase gate](./inactive-item-purchase-gating.md). Recorded that the 24h
104
+ persisted cache masks catalogue state **in both directions** (a switched-off item kept rendering
105
+ a working Add to Cart for anyone who had already viewed it; a switched-back-on item stayed
106
+ hidden), that the platform's only cache-bust is the Core parameter `META_LAST_REFRESH_DATETIME`
107
+ (`Core.Parameters` id 32, polled in `AuthContext` at `staleTime: 0` on route change and window
108
+ focus, and which **logs out every user on the environment**), and — the non-obvious part — that
109
+ `staleTime: 0` + `gcTime: 0` is **not** sufficient because `gcTime` only evicts *inactive*
110
+ queries while an *active* query is still dehydrated and persisted, so the item-detail keys also
111
+ had to be excluded via `dehydrateOptions.shouldDehydrateQuery` in `App.tsx`. Listings
112
+ deliberately left on the default cache. (bala)
@@ -0,0 +1,197 @@
1
+ ---
2
+ title: Inactive-item purchase gating (standalone lines only — kits are exempt by design)
3
+ framework: "2.0"
4
+ repo: toga2-commerce
5
+ project: TOGa Commerce
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-14
10
+ owners: ["bala"]
11
+ files:
12
+ - toga2-commerce/src/utils/checkIsItemPurchasable.ts
13
+ - toga2-commerce/src/api/fetchItemsActiveStatus.ts
14
+ - toga2-commerce/src/hooks/useCartReconciliation.ts
15
+ - toga2-commerce/src/stores/useCartStoreZu.ts
16
+ - toga2-commerce/src/pages/ItemsView/api/ItemsApi.ts
17
+ - toga2-commerce/src/pages/ItemsView/viewModel/useItemDetailsViewModel.ts
18
+ - toga2-commerce/src/pages/ItemsView/ItemViewPage.tsx
19
+ - toga2-commerce/src/components/AuthLayout/AuthLayout.tsx
20
+ - toga2-commerce/src/pages/Cart/CartPage.tsx
21
+ - toga2-commerce/src/pages/Cart/view/cartTable/CartTableBundleItem.tsx
22
+ - toga2-commerce/src/api/syncSalesOrderFromApiToLocalStorage.ts
23
+ - _underscore/Model/Compass/SalesOrder.php
24
+ - dbchanges2/Client_Compass/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql
25
+ - dbchanges2/Client_CompassCanada/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql
26
+ related:
27
+ - ./catalog-cache-freshness.md
28
+ - ./order-submit-sync-sequencing.md
29
+ - ./cart-bundle-submission-and-identity.md
30
+ - ../architecture.md
31
+ - ../../api2/features/api-payload-interceptors.md
32
+ - ../../api2/features/v2-rest-query-contract.md
33
+ - ../../../clients/compass-usa/profile.md
34
+ - ../../../clients/compass-canada/profile.md
35
+ ---
36
+
37
+ ## Summary
38
+
39
+ An item with **`Items.isActive = 0`** must not be viewable, addable to a cart, or orderable **as a
40
+ standalone line** on the storefront — but the **same flag is used for a completely different
41
+ purpose on kit components**, and nothing in the schema separates the two meanings.
42
+
43
+ **A Compass kit hero is switched off ON PURPOSE so it cannot be bought separately.** Measured on
44
+ dev-sandbox `Client_Compass`, across bundles that are themselves active:
45
+
46
+ | Component tier | inactive | active |
47
+ |---|---|---|
48
+ | PRIMARY | 87 | 4 |
49
+ | secondary | 134 | 216 |
50
+ | tertiary | 86 | 520 |
51
+
52
+ **84 of 85 active bundles contain at least one inactive component.** Canada is the same shape
53
+ (32 inactive vs 3 active primaries). So a gate on the flag alone would break almost every kit on
54
+ the storefront.
55
+
56
+ **Therefore the gate is on the purchase CONTEXT, never on the flag alone.** The discriminator is
57
+ whether the line belongs to a kit: `isset($payload->bundleItem)` server-side, the bundle wrapper
58
+ client-side. Kit lines are never evaluated.
59
+
60
+ ## How it works
61
+
62
+ Five layers, front to back. Each one is independently sufficient for its own entry point; together
63
+ they close the paths a single check would leave open.
64
+
65
+ | # | Layer | Where |
66
+ |---|---|---|
67
+ | 1 | Item page renders an "unavailable" panel instead of Add to Cart | `ItemViewPage.tsx`, `useItemDetailsViewModel.ts` |
68
+ | 2 | One shared purchasability helper, **fails closed** | `src/utils/checkIsItemPurchasable.ts` |
69
+ | 3 | Guard inside the cart store's `addItem` | `src/stores/useCartStoreZu.ts` |
70
+ | 4 | Cart reconciliation against the live catalog | `useCartReconciliation.ts`, `CartPage.tsx` |
71
+ | 5 | Server-side PRE interceptor on the order write | `_Model_Compass_SalesOrder::prePost` / `::prePut` |
72
+
73
+ ### 1–2. The helper fails CLOSED, and that is the whole point
74
+
75
+ ```ts
76
+ const ACTIVE_VALUES: unknown[] = [true, 1, '1'];
77
+ // returns false for a missing item, a non-object, and an absent isActive
78
+ ```
79
+
80
+ The check it replaced was inline `item?.isActive === false`, which **fails OPEN**: when the API
81
+ response omits `isActive`, `undefined === false` is `false` and the item reads as purchasable.
82
+ Whether the field arrives at all depends on it being listed in that tenant's `FIELDS` config — so
83
+ the old check silently stopped protecting anything the moment a config dropped the field.
84
+ `ACTIVE_VALUES` accepts the boolean, numeric and string forms so a serializer change cannot hide a
85
+ live item either.
86
+
87
+ ### 3. The store is the choke point, not the buttons
88
+
89
+ There are **five** add-to-cart call sites. Rather than guard each, the check lives inside
90
+ `useCartStoreZu.addItem` — every path funnels through it, so a new button cannot be added without
91
+ inheriting the guard.
92
+
93
+ ### 4. Reconciliation re-checks the persisted cart
94
+
95
+ The cart is a persisted Zustand store, so a line can outlive the item's deactivation.
96
+ `reconcileWithCatalog()` re-reads the live catalog and drops lines that are no longer purchasable.
97
+ It runs on **boot**, on **cart-overlay open**, and **pre-checkout** (`CartPage.tsx`).
98
+
99
+ ### 5. Server-side PRE interceptor (records id 14, `sales-orders`)
100
+
101
+ `_Model_Compass_SalesOrder::prePost` / `::prePut` both call
102
+ `checkAllStandaloneItemsAreActive()`, which collects standalone item uuids from the payload, runs
103
+ **one** `SELECT Items.partNumber … WHERE Items.uuid IN (…) AND Items.isActive = 0`, and throws
104
+ `_Exception_Validation` naming the offending part numbers. Reaching that throw means a front-end
105
+ guard leaked, so it also `error_log`s.
106
+
107
+ Registered by
108
+ `dbchanges2/Client_{Compass,CompassCanada}/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql`
109
+ as `(apiId NULL, recordId 14, PRE, POST)` and `(apiId NULL, recordId 14, PRE, PUT)`.
110
+
111
+ ## Scoping — by app slug and by FIELDS flag, never by client name
112
+
113
+ - **Server:** the check returns early unless `$api->app->slug === 'commerce'`. Supply, NetSuite,
114
+ EDI and admin corrections must still be able to write lines referencing deactivated items.
115
+ It uses the **slug**, not `Apps.id`, because **only `Core.Records` and `Core.RecordFields` carry
116
+ team-maintained ids** — `Apps.id` differs per environment, so an id literal would silently scope
117
+ to the wrong app somewhere.
118
+ - **Front end:** the new list-form item fetch and the unavailable panel are gated on the
119
+ **`getItemsByPersona.isEnabled` FIELDS flag**, not on a client name. QUAD is the only tenant with
120
+ it enabled, and QUAD keeps the old single-uuid persona-filtered path **byte-for-byte unchanged**.
121
+ A future tenant opts in by *not* enabling that flag, with no code change.
122
+
123
+ ## Editing a placed order KEEPS an already-inactive line
124
+
125
+ Only a **newly added** inactive item is refused. An order that already contains a line whose item
126
+ has since been deactivated must still be editable.
127
+
128
+ **The trap this avoids is data loss, not a bad error message.** The submit path rebuilds its
129
+ payload from the cart, and `processItems(…, getDeleteItems = true)` treats **any existing order
130
+ line missing from that list as deleted** and issues `deleteData('sales-order-items', uuid)`. So if
131
+ the edit-order rebuild had simply refused to re-add the inactive line, the first time an admin
132
+ saved any edit — even a quantity change on a different line — that line would have been
133
+ **permanently deleted from a placed order**.
134
+
135
+ Implementation:
136
+
137
+ - **`restoreExistingOrderLine`** — a store action used *only* by the edit-order rebuild
138
+ (`syncSalesOrderFromApiToLocalStorage.ts`). It bypasses the purchasability check, shows no
139
+ notice, and does not duplicate.
140
+ - **`reconcileWithCatalog` no-ops while `inEditMode`**, so reconciliation cannot strip the line
141
+ either.
142
+ - **No `prePut` interceptor is registered on records id 15 (`sales-order-items`)** — deliberately.
143
+ A record-15 guard would block quantity edits on pre-existing inactive lines. The flat
144
+ `PUT /sales-order-items/{uuid}` used for existing lines is therefore not covered by the
145
+ record-14 interceptor at all, and that is the intended design, not an oversight.
146
+
147
+ ## Gotchas / known issues
148
+
149
+ - **⚠ `Items.isActive = 0` means two different things and nothing distinguishes them.** Never gate
150
+ on the flag alone. Only a **standalone** line is judged; the kit-membership check
151
+ (`bundleItem` present / absent) is what makes the flag safe to read.
152
+ - **⚠ A failed lookup must never empty a cart.** `fetchItemsActiveStatus` originally returned `[]`
153
+ when `data.items` was missing or not an array. Reconciliation reads an empty list as *"every item
154
+ I asked about is gone"* and clears the cart — so a malformed `200` walked straight past the
155
+ `try`/`catch` that was supposed to guarantee a lookup failure was harmless. It now **throws**, so
156
+ the caller's `catch` leaves the cart untouched; a genuine zero-row answer still returns a real
157
+ empty array. Any "reconcile against the server" routine needs this distinction between *empty*
158
+ and *unknown*.
159
+ - **⚠ Judge only the uuids you actually queried.** The reconcile `set` filtered the *current*
160
+ `state.items` against results computed from a snapshot taken **before** the `await`, so a line
161
+ added while the lookup was in flight was removed without ever being checked. Fixed by scoping the
162
+ removal to the queried uuid set.
163
+ - **⚠ Remove-then-add is a data-loss ordering bug once an add can fail.** Splitting an item out of
164
+ a kit used to remove it from the bundle *before* adding it as a standalone line. With the guard
165
+ in place, a refused add would have made the item vanish entirely. The add is now attempted first
166
+ and the kit removal happens **only on success** (`CartTableBundleItem.tsx`). Audit every
167
+ "move between containers" flow when you introduce a rejection path.
168
+ - **⚠ `GET /items/{uuid}` silently ignores `where`.** The `isActive` filter only applies on the
169
+ **list** route. See
170
+ [V2 REST query contract](../../api2/features/v2-rest-query-contract.md#-a-where-clause-on-a-single-uuid-read-is-silently-ignored).
171
+ - **⚠ The 24h persisted React Query cache hides catalogue state in both directions** — a user who
172
+ had already opened an item kept seeing a working product page after it was switched off. That is
173
+ why the item-detail queries are `staleTime: 0` + `gcTime: 0` **and** excluded from persistence.
174
+ See [catalog cache freshness](./catalog-cache-freshness.md).
175
+ - **The server check is one query per order, not per line** — uuids are deduped into a single
176
+ `IN (…)` lookup, and the empty list returns early because `IN ()` is a MySQL syntax error.
177
+
178
+ ## Change history
179
+
180
+ - 2026-08-14 — TRUE-80489: built the layered gate stopping an `Items.isActive = 0` item being
181
+ viewed, carted or ordered as a **standalone** line, with kit lines exempt. Recorded the measured
182
+ reason the flag alone is unusable (84 of 85 active Compass bundles contain an inactive component;
183
+ 87 inactive vs 4 active PRIMARY components) and the `bundleItem` context discriminator. Layers:
184
+ item-page unavailable panel; one fail-closed `checkIsItemPurchasable` helper replacing an inline
185
+ `isActive === false` that failed **open** whenever FIELDS omitted the field; a guard inside
186
+ `useCartStoreZu.addItem` (all five add call sites funnel through it); catalog reconciliation on
187
+ boot / overlay-open / pre-checkout; and `_Model_Compass_SalesOrder::prePost`/`::prePut` behind
188
+ `(recordId 14, PRE, POST|PUT)` rows in both Compass tenants. Scoped by `$api->app->slug ===
189
+ 'commerce'` (slug, not the per-environment `Apps.id`) and by the `getItemsByPersona.isEnabled`
190
+ FIELDS flag, leaving QUAD unchanged. Decided that **editing a placed order keeps an
191
+ already-inactive line** — `processItems(getDeleteItems = true)` deletes any existing line absent
192
+ from the rebuilt cart, so refusing it would permanently delete the line — via
193
+ `restoreExistingOrderLine` plus `reconcileWithCatalog` no-opping in edit mode, and deliberately
194
+ registered **no** record-15 `prePut`. Fixed two PR-review defects: `fetchItemsActiveStatus`
195
+ returning `[]` on a malformed response (cart-clearing; now throws) and a race that removed lines
196
+ added during the in-flight lookup. Reordered the kit-split so the add precedes the bundle
197
+ removal. Shipped on TRUE-80489, merged to `_beta` and `_sandbox-dev`, verified on beta. (bala)
@@ -6,7 +6,7 @@ project: TOGa Commerce
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-11
9
+ updated: 2026-08-14
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - toga2-commerce/src/pages/OrderDetails/hooks/useSubmitOrder.ts
@@ -14,8 +14,10 @@ files:
14
14
  - toga2-commerce/src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts
15
15
  related:
16
16
  - ./cart-bundle-submission-and-identity.md
17
+ - ./inactive-item-purchase-gating.md
17
18
  - ../architecture.md
18
19
  - ../../api2/features/v2-rest-query-contract.md
20
+ - ../../api2/features/api-payload-interceptors.md
19
21
  - ../../../clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md
20
22
  ---
21
23
 
@@ -42,6 +44,35 @@ does not need child data must also send an explicit `depth: 1`.
42
44
  `updateSalesOrderEmails()` runs **first** inside `syncSalesOrderData()`, so it is the earliest
43
45
  header request on the wire and the one that collides with the delete burst.
44
46
 
47
+ ## ⚠ Lines are NEVER sent nested on the order write — they go one request at a time
48
+
49
+ This is the single most misread thing about this path. **Commerce does not post an order with its
50
+ `salesOrderItems` inside it.**
51
+
52
+ - `syncSalesOrdersDataFromLocalStorageCartToApi.ts` **line 20 `delete`s `salesOrderItems`** from the
53
+ edit `PUT` payload outright.
54
+ - `syncSalesOrderItemsFromLocalStorageCartToApi.ts` then writes lines **individually**:
55
+
56
+ | Line state | Request | `Core.Records` id |
57
+ |---|---|---|
58
+ | new | `POST /v2/sales-orders/{uuid}/sales-order-items` (nested route, body **is** the line) | resolves **14** first |
59
+ | existing | `PUT /v2/sales-order-items/{uuid}` (flat) | **15** |
60
+ | removed | `DELETE /v2/sales-order-items/{uuid}` | **15** |
61
+
62
+ **Consequences worth internalising:**
63
+
64
+ - **A server-side guard on the order must not look for `salesOrderItems`.** A `(recordId 14, PRE,
65
+ POST)` interceptor *does* fire on the nested per-item POST — `SalesOrders` is the first-resolved
66
+ route pair — but it receives a **single line object with no wrapper**. A collector that only reads
67
+ `$payload->salesOrderItems` silently sees nothing. Details in
68
+ [API payload interceptors](../../api2/features/api-payload-interceptors.md#a-pre-interceptor-on-a-parent-record-also-fires-on-the-nested-child-post--with-the-child-as-payload).
69
+ - **The flat `PUT` is a different record (15)** and is not reachable from a record-14 hook at all.
70
+ - **`processItems(…, getDeleteItems = true)` treats "absent from the rebuilt cart" as DELETE.** Any
71
+ existing order line that the cart does not re-emit is deleted from a placed order on the next
72
+ save. So a rebuild path that *skips* a line — for any reason, including refusing it — destroys it.
73
+ Worked example: [inactive-item purchase
74
+ gating](./inactive-item-purchase-gating.md#editing-a-placed-order-keeps-an-already-inactive-line).
75
+
45
76
  ## ⚠ The race — concurrent deletes vs. a depth-3 response
46
77
 
47
78
  On `_production`, `useSubmitOrder` ran:
@@ -100,9 +131,22 @@ Three changes, in `toga2-commerce`:
100
131
  sync call actually changes anything before assuming it must stay.
101
132
  - **The failure is a plain `EO-1` to the user** — the real exception only exists in `Logs.Issue`.
102
133
  Resolve `error.id` (`<Issue.reference>-<Event.eventNumber>`) rather than reading the response.
134
+ - **⚠ Omitting a line from the rebuilt cart DELETES it from a placed order.** `processItems` with
135
+ `getDeleteItems = true` derives the delete set by difference, so "don't re-add this one" and
136
+ "destroy this one" are the same instruction on this path.
103
137
 
104
138
  ## Change history
105
139
 
140
+ - 2026-08-14 — Recorded **how order lines are actually written**, learned while adding a
141
+ server-side order guard: the header sync `delete`s `salesOrderItems` from the edit `PUT` (line
142
+ 20), and lines are written **one request at a time** — new lines via the nested
143
+ `POST /sales-orders/{uuid}/sales-order-items` (which resolves `SalesOrders`, records id **14**,
144
+ first, and hands a PRE interceptor a **single line object with no `salesOrderItems` wrapper**),
145
+ existing lines via the flat `PUT /sales-order-items/{uuid}` (records id **15**, unreachable from a
146
+ record-14 hook). Also recorded that `processItems(…, getDeleteItems = true)` derives deletions by
147
+ difference, so any existing line the rebuilt cart does not re-emit is **permanently deleted** on
148
+ the next save of a placed order. (bala)
149
+
106
150
  - 2026-08-11 — Created from the production investigation of the checkout 500s: `useSubmitOrder`'s
107
151
  `Promise.all([syncSalesOrderData(), syncSalesOrderLocalStorage()])` raced `updateSalesOrderEmails()`'s
108
152
  default-depth-3 `PUT /v2/sales-order-email-addresses/{uuid}` (which walks
@@ -9,7 +9,7 @@
9
9
  | [Column Visibility (URL-driven show/hide columns)](features/column-visibility.md) | A "Columns" header button that opens a modal listing every column from the table meta, lets the user show/hide columns, adjusts the table live, and persists the | toga25-supply/src/components/ColumnVisibilityModal/, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx, toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableData.tsx |
10
10
  | [Force Logout on Deployment (useDeploymentGuard)](features/force-logout-on-deployment.md) | On large deployments the backend bumps the Core parameter `META_LAST_REFRESH_DATETIME`. | toga25-supply/src/hooks/useDeploymentGuard.tsx, toga25-supply/src/App.tsx |
11
11
  | [Meta-Driven Page & Table Setup](features/meta-driven-table-data.md) | A page in this app is **meta-driven end to end**: the page view model fetches *page meta* (labels, sections, ACL) and *table meta* (the columns/fields + table s | toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableState.ts, toga25-supply/src/hooks/useTablePageMeta.ts, toga-blox-npm/dist/hooks/useFetchPageMeta.d.ts, toga-blox-npm/dist/hooks/useFetchTablePageMeta.d.ts, toga-blox-npm/dist/hooks/useAssignTableFieldLabels.d.ts, toga-blox-npm/dist/components/Table/hooks/useTableData.d.ts |
12
- | [Record Modals & Nested Tables](features/record-modals-and-nested-tables.md) | The repo's family of modal + nested-table patterns layered over toga-blox `TableRecordModal` and `PrimaryTable*Layout`. | toga25-supply/src/layout/ItemRecordModalLayout/, toga25-supply/src/layout/SalesOrderRecordModalLayout/, toga25-supply/src/layout/SalesOrderItemsTableLayout/, toga25-supply/src/layout/ItemFulfillmentModal/, toga25-supply/src/layout/GenericNestedTables/GenericNestedTables.tsx, toga25-supply/src/layout/GenericNestedTables/GenericTableLayout.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/pages/Inventory/viewModel/FIELDS/DEFAULT/inventoryGroupings.json, toga25-supply/src/hooks/useTableCellInteractions.ts, toga25-supply/src/hooks/useServerTableUrlState.ts |
12
+ | [Record Modals & Nested Tables](features/record-modals-and-nested-tables.md) | The repo's family of modal + nested-table patterns layered over toga-blox `TableRecordModal` and `PrimaryTable*Layout`. | toga25-supply/src/layout/ItemRecordModalLayout/, toga25-supply/src/layout/SalesOrderRecordModalLayout/, toga25-supply/src/layout/SalesOrderItemsTableLayout/, toga25-supply/src/layout/ItemFulfillmentModal/, toga25-supply/src/layout/GenericNestedTables/GenericNestedTables.tsx, toga25-supply/src/layout/GenericNestedTables/GenericTableLayout.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/pages/Inventory/viewModel/FIELDS/DEFAULT/inventoryGroupings.json, toga25-supply/src/hooks/useTableCellInteractions.ts, toga25-supply/src/hooks/useServerTableUrlState.ts, toga25-supply/src/layout/RecordApprovalModal/helpers/handleFormatApprovalWorkflowPayload.ts, toga25-supply/src/layout/RecordApprovalModal/api/approvalDecisionsApi.ts |
13
13
  | [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/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/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/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/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 |
14
14
  | [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 |
15
15
  | [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 |
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-20
9
+ updated: 2026-08-14
10
10
  owners: [apeterson]
11
11
  files:
12
12
  - toga25-supply/src/layout/ItemRecordModalLayout/
@@ -19,6 +19,8 @@ files:
19
19
  - toga25-supply/src/pages/Inventory/viewModel/FIELDS/DEFAULT/inventoryGroupings.json
20
20
  - toga25-supply/src/hooks/useTableCellInteractions.ts
21
21
  - toga25-supply/src/hooks/useServerTableUrlState.ts
22
+ - toga25-supply/src/layout/RecordApprovalModal/helpers/handleFormatApprovalWorkflowPayload.ts
23
+ - toga25-supply/src/layout/RecordApprovalModal/api/approvalDecisionsApi.ts
22
24
  related:
23
25
  - ../architecture.md
24
26
  - meta-driven-table-data.md
@@ -189,8 +191,25 @@ Scaffold per the `ItemRecordModalLayout` pattern (`src/layout/ItemRecordModalLay
189
191
  for stages whose `note` / `assignedTo` / `decision` actually changed, and chooses verb by
190
192
  `stage.ApprovalDecisions.uuid` — `null` → POST `/approval-decisions`, otherwise PUT
191
193
  `/approval-decisions/{uuid}`.
194
+ - **Clearing a stage's assignee is a DELETE, not a dropped field.** The assignee is stored
195
+ **on** the `ApprovalDecisions` record, so to *unassign* a pending stage you must emit a
196
+ **DELETE `/approval-decisions/{uuid}`** — not a PUT with an empty value. The earlier bug: the
197
+ payload builder only added the assignee field when the new value was truthy
198
+ (`if (didAssignedToChanged && formAssignedToUuid)`), so clearing it produced **no** backend
199
+ change and the old assignment survived. Fix (`handleFormatApprovalWorkflowPayload`): emit a
200
+ DELETE item **only** when all three hold — the assignee actually changed to empty, the stage
201
+ has **no decision yet** (`isApproved` null/undefined; a decided/denied/approved or pre-approved
202
+ stage is excluded), and an existing `ApprovalDecisions.uuid` exists to delete.
203
+ `updateApprovalWorkflow` (`approvalDecisionsApi.ts`) gained a `method === "DELETE"` branch
204
+ calling `apiDelete(route)`.
192
205
 
193
206
  ## Change history
207
+ - 2026-08-14 — Fixed unassigning an approval-workflow stage: clearing the assignee now emits a
208
+ DELETE `/approval-decisions/{uuid}` (the assignment lives on the `ApprovalDecisions` record)
209
+ instead of silently dropping the field. Gated to fire only for a still-undecided stage
210
+ (`isApproved` null) with an existing decision uuid; `updateApprovalWorkflow` gained a
211
+ `DELETE` branch (`apiDelete`). Added the gotcha + the two RecordApprovalModal files.
212
+ (apeterson)
194
213
  - 2026-07-20 — Pattern 5: added `buildSwapTriggerColumns(triggerSwap)` and `getModalUuid(row)` to
195
214
  `TableLevel` so a swap level's "drill to child tier" (a dedicated "See units" chevron) and
196
215
  "open this record" (plain row click → `renderModal`) are decoupled, and a record modal can open a
@@ -29,7 +29,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
29
29
  - **talos** (TOGa IQ) — 7 doc(s) → [2.0/apps/talos/INDEX.md](2.0/apps/talos/INDEX.md)
30
30
  - **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
31
31
  - **ai-bdr** (AI-BDR) — 9 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
32
- - **toga2-commerce** (TOGa Commerce) — 13 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
32
+ - **toga2-commerce** (TOGa Commerce) — 15 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
33
33
  - **toga25-supply** (TOGa 2.5 Supply) — 11 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
34
34
  - **toga-blox** (TOGa Blox) — 8 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
35
35
  - **bdr** (BDR) — 0 doc(s) → [2.0/apps/bdr/INDEX.md](2.0/apps/bdr/INDEX.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.581",
3
+ "version": "1.0.583",
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",