toga-ai 1.0.552 → 1.0.553

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.
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-10
9
+ updated: 2026-08-11
10
10
  owners: ["dfranks", "jcardinal", "mhammontree", "ajean", "bala"]
11
11
  files:
12
12
  - _underscore/Error.php
@@ -33,6 +33,7 @@ related:
33
33
  - ../../../1.0/apps/tools/features/errors-curation-console.md
34
34
  - ../../../1.0/apps/library/features/error-capture-1-0.md
35
35
  - ../../api2/features/v2-api-error-codes.md
36
+ - ../../api2/features/request-logging.md
36
37
  - ../../dbchanges2/architecture.md
37
38
  - ../../../../1.0/apps/library/features/http-500-error-monitor.md
38
39
  ---
@@ -362,6 +363,19 @@ WHERE issueId = <issue.id> AND eventNumber = <n>;
362
363
  tenant that has gone quiet auto-resolves; check `dtLastOccurred` against the tenant's traffic
363
364
  before believing it (see
364
365
  [error-issue auto-resolution](../../worker2/features/error-issue-auto-resolution.md)).
366
+ - **Tell an AUTO-close from a real fix by the columns, not the status.** `dtAutoResolved`
367
+ populated **and `isManaged = 0`** means the frequency-decay cron closed it after its quiet
368
+ window — **nobody looked at the code**. On the listing that is visually identical to a defect a
369
+ human fixed, and it will silently reopen on the next occurrence. Before you accept "already
370
+ resolved", confirm a code or config change actually shipped. (This diverted a production
371
+ investigation on 2026-08-11.)
372
+ - **`Event.context` carries the per-occurrence request identity** — `requestMethod`,
373
+ `requestPath`, **`transactionId`**, `referer`, `userAgent`, `instanceId`, the Elastic Beanstalk
374
+ environment and the PHP version. `transactionId` is the field that collapses a burst of
375
+ near-identical occurrences back into the **one** user action that caused them, because apiproxy
376
+ retries a 5xx across regions under a single transaction id — see
377
+ [api2 request logging](../../api2/features/request-logging.md). Read occurrence counts through
378
+ it or you will overstate user impact.
365
379
  - The **1.0 alert surface** that fires on these is
366
380
  [App_SystemMonitor_500Error](../../../../1.0/apps/library/features/http-500-error-monitor.md) — its
367
381
  "Error Type" is a fallback literal with **zero** diagnostic value, which is why this recipe
@@ -617,6 +631,15 @@ clientUserId). **Neither was built.** As built instead:
617
631
 
618
632
  ## Change history
619
633
 
634
+ - 2026-08-11 — Sharpened the `error.id` lookup recipe from a production incident investigation
635
+ (Compass, api2 500s): **`status = 'RESOLVED'` with `dtAutoResolved` set and `isManaged = 0` is a
636
+ cron auto-close after a quiet window, not a fix** — it is indistinguishable from a real fix on
637
+ the listing and reopens on the next occurrence, so verify a code/config change actually shipped
638
+ (this misdirected the investigation once). Also recorded what `Event.context` actually carries
639
+ per occurrence (`requestMethod`, `requestPath`, `transactionId`, `referer`, `userAgent`,
640
+ `instanceId`, EB environment, PHP version) and that **`transactionId` is the field that collapses
641
+ an apiproxy cross-region retry storm back into one user action** — occurrence counts read
642
+ without it overstate impact. No code change. (bala)
620
643
  - 2026-08-10 — Added the **`error.id` lookup recipe** (`Logs.Api.responsePayload` → `Logs.Issue`
621
644
  by `reference` → `Logs.Event` by `eventNumber` for the tenant), because in production `error.id`
622
645
  is the **only** handle an api2 500 gives you — `message`/`trace` are debug-mode-gated so the
@@ -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/Elite/SalesOrder.php, dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.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/Elite/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderItem.php, dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.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,13 +6,15 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-10
9
+ updated: 2026-08-11
10
10
  owners: ["mhammontree", "dfranks", "bala"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - api2/Controller/Index.php
14
14
  - _underscore/Model/Client/ItemFulfillment.php
15
15
  - _underscore/Model/Elite/SalesOrder.php
16
+ - _underscore/Model/Compass/SalesOrder.php
17
+ - _underscore/Model/Compass/SalesOrderItem.php
16
18
  - dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql
17
19
  related:
18
20
  - ./record-scripts.md
@@ -22,6 +24,7 @@ related:
22
24
  - ../../_underscore/features/config-group-access.md
23
25
  - ../../worker2/features/creating-worker-actions.md
24
26
  - ../../../clients/elite/features/salesorder-netsuite-push.md
27
+ - ../../../clients/compass-usa/features/sales-order-line-renumbering.md
25
28
  ---
26
29
 
27
30
  ## Summary
@@ -106,9 +109,56 @@ model does not define takes the endpoint down** with a PHP fatal —
106
109
  mirror image of the "missing row" failure mode above: a *missing* row is silent, an *extra/wrong*
107
110
  row is fatal.
108
111
 
112
+ The post-processing site reads
113
+ `$clientModelName::{$interceptorEntry['phpMethod']}(api: $api, payload: $outData);` — `phpMethod`
114
+ is a key **derived in PHP at lookup time**, not a column, so nothing between the SQL row and the
115
+ call can reject a name the class does not implement.
116
+
109
117
  **Fix direction:** guard all four sites with `method_exists()` and raise a logged `Logs.Issue`
110
118
  instead of letting the request fatal.
111
119
 
120
+ **Measured blast radius (Compass, 2026-08-05 → 2026-08-06).** A single row wiring `POST`/`POST` on
121
+ `sales-order-items` resolved to `_Model_Compass_Usa_SalesOrderItem::postPost()`, which was never
122
+ written. Result: **270 failed Compass order submissions over ~15 hours** (2026-08-05 17:16 →
123
+ 2026-08-06 07:58), every one an opaque `EO-1` 500 with no message. This is the cost of the missing
124
+ guard — not a degraded response, a dead write endpoint for one tenant until someone deleted the row.
125
+
126
+ ## There is NO `Records` route for `api-payload-interceptors` — the rows are UNAUDITED
127
+
128
+ `ApiPayloadInterceptors` has **no `Core.Records` row**, therefore no `/v2` route, therefore no ACL,
129
+ no `Logs.Api` entry, and **no change history of any kind**. Every add, edit, and delete is direct
130
+ SQL against the client database.
131
+
132
+ Practical consequences when you are investigating:
133
+
134
+ - **You cannot answer "who added this row and when."** The 270-order outage above was resolved by
135
+ deleting a row, and there is no record anywhere of who created it or when — the only evidence is
136
+ the shape of the 500s in `Logs.Api`.
137
+ - **A fix by row-deletion leaves no trace either.** "It stopped happening" is the whole audit trail.
138
+ Record the before/after row set in the ticket or in this KB, because the database will not.
139
+ - **Snapshot the table before you change it** (`SELECT` the client's full row set to the ticket).
140
+ It is a small table and the prod-vs-environment full-table compare is already the standard
141
+ diagnostic — that same dump is the only rollback you have.
142
+
143
+ ## `postDelete` can NEVER run — delete-time logic must live in `preDelete`
144
+
145
+ The post-processing interceptor block in `api2/Component/Api/V2/V2.php` (~L5692) executes **only
146
+ when `$outData` is non-null**, and a **successful DELETE always sets `$outData = null`**. So a
147
+ `(recordId, POST, DELETE)` row is dead on arrival: the method is never called, and — exactly like
148
+ the missing-row case — there is no error and no log line to tell you.
149
+
150
+ **Therefore any logic that must react to a deletion runs in `preDelete`, and must exclude the row
151
+ that is about to disappear**, because at `preDelete` time it is still present. The Compass line
152
+ renumberer takes the id explicitly for this reason:
153
+ `renumberLineNumbers($salesOrderId, excludeSalesOrderItemId: $id)`. See
154
+ [Compass sales-order line renumbering](../../../clients/compass-usa/features/sales-order-line-renumbering.md)
155
+ for the worked wiring.
156
+
157
+ Corollary for design: when delete-time work also needs the *parent* to be consistent afterwards,
158
+ prefer hooking the **parent** record's `postPost`/`postPut` — the parent hook fires reliably as the
159
+ request's first-resolved record, whereas the child hook has both the `$outData` constraint above and
160
+ the undefined-method risk.
161
+
112
162
  ### The client-model resolution that decides which class must define the method
113
163
 
114
164
  At `~L5681-5690` the engine derives the concrete class by `str_replace`-ing `_Model_Client_` →
@@ -223,7 +273,15 @@ and the failing environment**. It is a small table, and the drift is usually exa
223
273
  - **⚠ An interceptor row for a method that does not exist is a production outage, not a no-op.**
224
274
  The dispatch is unguarded at all four call sites — the endpoint fatals with *"Call to undefined
225
275
  method"*. If a write endpoint suddenly 500s for one client only, list that client's
226
- `ApiPayloadInterceptors` rows and confirm every derived method exists.
276
+ `ApiPayloadInterceptors` rows and confirm every derived method exists. Measured cost of one such
277
+ row: **270 failed Compass order submissions in ~15 hours.**
278
+ - **⚠ A `(POST, DELETE)` row is silently dead** — the post block runs only when `$outData` is
279
+ non-null and a successful DELETE nulls it. Use `preDelete` (excluding the row being deleted), or
280
+ hook the parent record instead.
281
+ - **⚠ These rows have no route, no ACL and no audit trail.** `api-payload-interceptors` has no
282
+ `Core.Records` row, so every change is direct SQL and nothing records who made it. Snapshot the
283
+ client's row set into the ticket before and after any change — that dump is your only rollback
284
+ and your only history.
227
285
  - **⚠ A migration that enables an interceptor must insert `isActive = 0`.** Activation is a *data*
228
286
  change that switches on a *code* path; if the PHP defining the method is not confirmed deployed to
229
287
  that environment, the row takes the endpoint down. Insert inactive, verify the deploy, then flip
@@ -252,6 +310,19 @@ and the failing environment**. It is a small table, and the drift is usually exa
252
310
 
253
311
  ## Change history
254
312
 
313
+ - 2026-08-11 — Three findings from the post-mortem of the `_Model_Compass_Usa_SalesOrderItem::postPost()`
314
+ outage (investigation only, no code change). (1) **`postDelete` can never fire** — the
315
+ post-processing block at `V2.php` ~L5692 runs only when `$outData` is non-null and a successful
316
+ DELETE always sets it to null, so a `(recordId, POST, DELETE)` row is silently dead; delete-time
317
+ logic must run in `preDelete` and **exclude the row about to be deleted**, or hook the parent
318
+ record, which fires reliably as the request's first-resolved record. (2) **These rows are
319
+ completely unaudited** — `api-payload-interceptors` has no `Core.Records` row, hence no route, no
320
+ ACL, no `Logs.Api` entry and no history; adds and removes leave no evidence, so snapshot the
321
+ client's row set into the ticket before/after. (3) Quantified the unguarded-dispatch failure:
322
+ **270 failed Compass order submissions between 2026-08-05 17:16 and 2026-08-06 07:58**, all opaque
323
+ `EO-1` 500s, ended by deleting one row. Also recorded that the post call site invokes a
324
+ runtime-derived `$interceptorEntry['phpMethod']`, so nothing validates the name before the call.
325
+ (bala)
255
326
  - 2026-08-10 — Added **Queueing background work from a post interceptor**, five verified rules from
256
327
  the Elite sales-order push: (1) `internalApiRequest` returns records **flat** —
257
328
  `$response->salesOrders`, not `$response->data->salesOrders` (the `data` envelope exists only on a
@@ -6,8 +6,8 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-10
10
- owners: ["mhammontree", "dfranks"]
9
+ updated: 2026-08-11
10
+ owners: ["mhammontree", "dfranks", "bala"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - api2/Controller/Index.php
@@ -85,6 +85,27 @@ Practical consequences:
85
85
  - **Orphan sweeps only find PARTIAL casualties.** An attempt that rolled back completely leaves no
86
86
  row in any table and, with no API log row, no trail at all.
87
87
 
88
+ ## ⚠ apiproxy's 5xx retry distorts BOTH logs — always read a 500 through `transactionId`
89
+
90
+ apiproxy retries a 5xx **across regions**, so **one user action produces up to six near-identical
91
+ 500 rows** in core `Logs.Api`: same route, same payload, **one shared `transactionId`**, ~2 s apart,
92
+ alternating `instanceId`s. Two consequences, and both have already caused wrong conclusions:
93
+
94
+ 1. **Occurrence counts overstate user impact.** A "burst" is usually one person clicking once.
95
+ Collapse on `transactionId` before you report how many users were affected — the same applies to
96
+ `Logs.Issue.totalOccurrences` and to the 1.0 alert's repeat count
97
+ ([HTTP 500 monitor](../../../1.0/apps/library/features/http-500-error-monitor.md)).
98
+ 2. **A retry that succeeds hides the failure from the client log.** The failed attempts land only
99
+ in **core `Logs.Api`** (failed writes are core-logged — see above), while the retry that
100
+ returned 200 is written to **`Logs_<Client>.Api`**. So the client log shows a **clean 200 for a
101
+ request that 500'd first**, and a client-log-only audit reads a partially-failing endpoint as
102
+ healthy.
103
+
104
+ **`Logs_<Client>.Api.transactionId` is UNIQUE**, so there is at most **one** client-log row per
105
+ transaction no matter how many times apiproxy retried. That is why the retry attempts cannot appear
106
+ there even in principle — the two logs are not two views of the same rows. Join the two by
107
+ `transactionId` when you need the full attempt sequence for one user action.
108
+
88
109
  ## Blind spots when auditing a client's API traffic
89
110
 
90
111
  Looking only at `Logs_<Client>.Api` misses four sources:
@@ -156,6 +177,15 @@ re-send.
156
177
 
157
178
  ## Change history
158
179
 
180
+ - 2026-08-11 — Documented how **apiproxy's cross-region 5xx retry distorts both logs**, found while
181
+ reading prod 500s for Compass: one user action writes up to **six** near-identical core `Logs.Api`
182
+ 500 rows sharing a single `transactionId` (~2 s apart, alternating `instanceId`), so occurrence
183
+ counts overstate impact; and when a retry **succeeds**, the 200 is written to `Logs_<Client>.Api`
184
+ while the failed attempts exist **only** in core `Logs.Api` — so the client log can show a clean
185
+ 200 for a request that 500'd first, and a client-log-only audit reads a partially-failing endpoint
186
+ as healthy. Recorded that **`Logs_<Client>.Api.transactionId` is UNIQUE** (at most one client-log
187
+ row per transaction), which is why the retry attempts cannot appear there, and that
188
+ `transactionId` is the join key for reconstructing one user action. No code change. (bala)
159
189
  - 2026-08-10 — Added **Querying prod `Logs.Api` without timing out**: any `LIKE` over the
160
190
  `mediumtext` `requestPayload`/`responsePayload` times out the `toga_query` MCP call at 300 s even
161
191
  for one day. Narrow first on indexed/non-text columns (`dtStamp` + `route` + `responseCode`, or a
@@ -49,6 +49,35 @@ controller.
49
49
  **`EV-13`** (HTTP 400).
50
50
  - **Sort:** `-field` = DESC, bare `field` = ASC. **Do not send `+`** for ascending.
51
51
 
52
+ ### ⚠ `depth` is not free — the default 3 re-reads child rows and races concurrent deletes
53
+
54
+ **`depth` defaults to 3 on every request, writes included.** Serialization (`getFullModelData()` in
55
+ `api2/Component/Api/V2/V2.php`) walks the relationship graph to that depth and **re-loads each child
56
+ by primary key** through `_Model::initialize()` (`_underscore/Model.php` ~L705). `_Model`'s build
57
+ **throws when the row is gone**:
58
+
59
+ ```
60
+ Error during Model build for primary key id 'NNNNNN' for '_Model_<Client>_SalesOrderItem'.
61
+ Exactly 1 row was expected to be returned but 0 were.
62
+ ```
63
+
64
+ Nothing catches it, so it surfaces as an **opaque `EO-1` HTTP 500** on a request that did nothing
65
+ wrong. (This is a *different* path from the dangling-FK tolerance in
66
+ [cross-client data retrieval](cross-client-data-retrieval.md), which nulls a failed **FK expansion**
67
+ — the child-collection re-load is not covered by it.)
68
+
69
+ **The rule for callers:** if another request may be deleting rows in a collection concurrently, a
70
+ response that walks that collection can 500. Two defences, both cheap:
71
+
72
+ - **Send an explicit `depth` — do not inherit the default.** A write that only needs its own row
73
+ should send `depth: 1`; that alone stops the serializer from touching the child collection.
74
+ - **Serialize the writes.** Do not run a request that deletes children in parallel with one whose
75
+ response walks those children. Worked example (a real production 500 burst):
76
+ [TOGa Commerce order-submit sync sequencing](../../toga2-commerce/features/order-submit-sync-sequencing.md).
77
+
78
+ The fragility itself is **unfixed** — any depth ≥ 2 response is exposed. Lowering depth removes
79
+ *your* exposure, not the defect.
80
+
52
81
  ### CRITICAL — an unrecognized param silently flips LIST → READ
53
82
 
54
83
  Any query param the engine does not recognize switches the request onto a **different code
@@ -129,6 +158,15 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
129
158
  invisible; re-request with `fields=` to force `EZ-2` and see the denied list.
130
159
 
131
160
  ## Change history
161
+ - 2026-08-11 — Recorded that **`depth` (default 3) makes a response race concurrent deletes**:
162
+ `getFullModelData()` re-loads each child by primary key via `_Model::initialize()`, whose build
163
+ **throws** when the row is gone (*"Exactly 1 row was expected to be returned but 0 were"*),
164
+ uncaught → opaque `EO-1` 500. Confirmed in production from a `PUT` at default depth walking
165
+ `salesOrder → salesOrderItems` while a concurrent `DELETE /v2/sales-order-items/*` burst removed
166
+ those exact rows. Defences for callers: send an explicit `depth: 1` on writes that need only their
167
+ own row, and never run a child-deleting request in parallel with one whose response walks that
168
+ collection. Noted this is a **different** path from the dangling-FK tolerance (which nulls a failed
169
+ FK expansion) and that the underlying fragility is unfixed for any depth ≥ 2. (bala)
132
170
  - 2026-08-10 — Recorded that **`fields=` narrows the response but not the SQL SELECT**: `_Model`
133
171
  selects every property declared on the class **and its traits**, so a declared-but-missing column
134
172
  500s (`EO-1`, MySQL 1054) any request touching that record — including one that named only an
@@ -10,5 +10,6 @@
10
10
  | [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 |
11
11
  | [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 |
12
12
  | [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 |
13
+ | [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 |
13
14
  | [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 |
14
15
  | [Cart e2e — Cypress conventions & harness (toga2-commerce)](workflows/cypress-testing.md) | The Cypress **e2e** convention set for `toga2-commerce`, and the first **active** e2e coverage for the **Cart** page (`cartV2.cy.ts`, slice 1 — 12 tests, verifi | toga2-commerce/cypress/e2e/cartPage/cartV2.cy.ts, toga2-commerce/cypress/fixtures/cart/fetchSingleUserAdmin.json, toga2-commerce/cypress/fixtures/cart/fetchLocations.json, toga2-commerce/cypress/fixtures/cart/fetchUserShippingMethods.json, toga2-commerce/cypress/support/commands.ts, toga2-commerce/cypress/support/e2e.ts, toga2-commerce/src/pages/Cart/CartPage.tsx, toga2-commerce/src/pages/Cart/view/cartForm/CartForm.tsx, toga2-commerce/src/pages/Cart/view/cartForm/CartFormSection.tsx, toga2-commerce/src/pages/Cart/view/cartTable/CartContentsTable.tsx, toga2-commerce/src/pages/Cart/view/cartTable/CartTableItem.tsx, toga2-commerce/src/components/Inputs/AdvancedInput.tsx, toga2-commerce/src/components/BaseButton/BaseButton.tsx |
@@ -0,0 +1,115 @@
1
+ ---
2
+ title: Order-submit sync sequencing (useSubmitOrder) — why these calls must not run in parallel
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-11
10
+ owners: ["bala"]
11
+ files:
12
+ - toga2-commerce/src/pages/OrderDetails/hooks/useSubmitOrder.ts
13
+ - toga2-commerce/src/api/syncSalesOrdersDataFromLocalStorageCartToApi.ts
14
+ - toga2-commerce/src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts
15
+ related:
16
+ - ./cart-bundle-submission-and-identity.md
17
+ - ../architecture.md
18
+ - ../../api2/features/v2-rest-query-contract.md
19
+ - ../../../clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md
20
+ ---
21
+
22
+ ## Summary
23
+
24
+ Submitting an order from the cart fires **two independent sync routines** — one for the sales-order
25
+ header (`syncSalesOrderData`) and one for the line items (`syncSalesOrderLocalStorage`). Running them
26
+ **concurrently is not safe**: the header sync's responses are serialized by api2 at the **default
27
+ depth 3**, which walks down into `salesOrderItems` and re-reads each line by primary key, while the
28
+ item sync is deleting those exact lines. The loser of that race is an **uncaught api2 500**.
29
+
30
+ **The rule: the item sync must complete before the header sync starts.** Anything on this path that
31
+ does not need child data must also send an explicit `depth: 1`.
32
+
33
+ ## How it works
34
+
35
+ `useSubmitOrder` drives the submit. The two routines are:
36
+
37
+ | Routine | File | What it does |
38
+ |---|---|---|
39
+ | `syncSalesOrderData()` | `src/api/syncSalesOrdersDataFromLocalStorageCartToApi.ts` | header-level writes, including `updateSalesOrderEmails()` |
40
+ | `syncSalesOrderLocalStorage()` | `src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts` | reconciles the cart's lines against the order — **issues `DELETE /v2/sales-order-items/{uuid}` for removed lines** |
41
+
42
+ `updateSalesOrderEmails()` runs **first** inside `syncSalesOrderData()`, so it is the earliest
43
+ header request on the wire and the one that collides with the delete burst.
44
+
45
+ ## ⚠ The race — concurrent deletes vs. a depth-3 response
46
+
47
+ On `_production`, `useSubmitOrder` ran:
48
+
49
+ ```ts
50
+ await Promise.all([syncSalesOrderData(), syncSalesOrderLocalStorage()]);
51
+ ```
52
+
53
+ `updateSalesOrderEmails()` issued `PUT /v2/sales-order-email-addresses/{uuid}` at the **default
54
+ depth 3**. api2 serializes that response by walking
55
+ `salesOrderEmailAddresses → salesOrder → salesOrderItems` and re-loading each child by primary key,
56
+ while `syncSalesOrderLocalStorage()` was concurrently deleting line items. Result:
57
+
58
+ ```
59
+ Error during Model build for primary key id 'NNNNNN' for '_Model_Compass_Usa_SalesOrderItem'.
60
+ Exactly 1 row was expected to be returned but 0 were.
61
+ ```
62
+
63
+ Uncaught → HTTP 500 → opaque `EO-1` for the user, at the last click of the checkout flow.
64
+
65
+ **Confirmed, not inferred.** The production log timeline shows a `DELETE /v2/sales-order-items/*`
66
+ burst at 14:53:36–38 straddling the failing `PUT` at 14:53:37, and all six `SalesOrderItem` ids
67
+ named in the exception are absent from the database.
68
+
69
+ ## The fix (TRUE-80551)
70
+
71
+ Three changes, in `toga2-commerce`:
72
+
73
+ 1. **Drop the `PUT` entirely.** `updateSalesOrderEmails()` was writing `existingEmail.emailAddress`
74
+ back over itself — a **no-op self-overwrite**. The request that caused the outage had no effect
75
+ in the first place.
76
+ 2. **Add `depth: 1` to the remaining `POST`**, so the response never walks the item collection.
77
+ 3. **Serialize the two routines** — `syncSalesOrderLocalStorage()` runs to completion *before*
78
+ `syncSalesOrderData()`.
79
+
80
+ > **STATUS (2026-08-11): merged to `origin/_beta`, NOT in `origin/_production`.** The production
81
+ > defect is still live — `Logs.Issue` reference `3T` (id 72) is **OPEN**, urgency HIGH, 6
82
+ > occurrences, last seen 2026-08-05 15:21. This needs a production deploy; do not treat the
83
+ > incident as closed because the branch is merged.
84
+
85
+ ## Gotchas / known issues
86
+
87
+ - **The underlying api2 fragility is NOT fixed.** `getFullModelData()` re-reads children by primary
88
+ key with no tolerance for a row that vanished mid-request, so **any** depth ≥ 2 response that
89
+ walks a child collection can still 500 if something concurrently deletes one of those rows.
90
+ TRUE-80551 removed the request that was hitting it; it did not remove the hazard. See
91
+ [V2 REST query contract → `depth` is not free](../../api2/features/v2-rest-query-contract.md).
92
+ - **`depth` defaults to 3 on every call, including writes.** A write helper that omits `depth` is
93
+ opting into a deep read of the order graph it did not ask for. Send `depth: 1` unless you
94
+ genuinely consume the children.
95
+ - **`Promise.all` over two order-sync routines is the anti-pattern here**, not a performance win.
96
+ The two routines touch the same rows; the parallelism buys milliseconds and costs a 500 at
97
+ checkout.
98
+ - **A self-overwriting write is invisible in review but not on the wire.** The failing `PUT` looked
99
+ like normal maintenance of the email record; it wrote the value it had just read. Check whether a
100
+ sync call actually changes anything before assuming it must stay.
101
+ - **The failure is a plain `EO-1` to the user** — the real exception only exists in `Logs.Issue`.
102
+ Resolve `error.id` (`<Issue.reference>-<Event.eventNumber>`) rather than reading the response.
103
+
104
+ ## Change history
105
+
106
+ - 2026-08-11 — Created from the production investigation of the checkout 500s: `useSubmitOrder`'s
107
+ `Promise.all([syncSalesOrderData(), syncSalesOrderLocalStorage()])` raced `updateSalesOrderEmails()`'s
108
+ default-depth-3 `PUT /v2/sales-order-email-addresses/{uuid}` (which walks
109
+ `salesOrder → salesOrderItems`) against a concurrent `DELETE /v2/sales-order-items/{uuid}` burst,
110
+ producing *"Exactly 1 row was expected to be returned but 0 were"* → uncaught → 500. Confirmed by
111
+ the prod log timeline (deletes 14:53:36–38 around the failing PUT at 14:53:37) and by all six
112
+ referenced item ids being absent from the database. Fix on branch TRUE-80551 — drop the PUT (it was
113
+ a no-op self-overwrite), send `depth: 1` on the remaining POST, and serialize the item sync before
114
+ the header sync — **merged to `_beta` only; `Logs.Issue` `3T` is still OPEN in production**.
115
+ Recorded the residual `getFullModelData()` fragility that the fix does not remove. (bala)
@@ -18,7 +18,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
18
18
 
19
19
  ## 2.0 framework
20
20
 
21
- - **_underscore** (_Underscore) _(framework core)_ — 50 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
+ - **_underscore** (_Underscore) _(framework core)_ — 51 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 46 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 22 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 8 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -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) — 11 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
32
+ - **toga2-commerce** (TOGa Commerce) — 12 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)
@@ -11,6 +11,7 @@
11
11
  | [Compass MITS Sales-Order Transmission — Rejection Alerting (Issue/Event, not email-in-cron)](features/mits-sales-order-transmission-alerting.md) | 2.0 | When MITS **rejects** a Compass sales order transmitted by the 1.0 cron `1_transmit_compass_sales_orders_to_mits.php`, the alert is no longer an email built ins | worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php, worker2/Worker/Infrastructure/Errors.php, _underscore/Model/Core/Logs/Issue.php, _underscore/Model/Core/Logs/IssueEmailAddress.php, dbchanges2/Logs/2026-08-04a - MITS rejection business recipients.sql |
12
12
  | [Compass MR/MA Order Auto-Approval & Status Gate](features/mr-ma-order-approval-and-status.md) | 2.0 | Compass **MR** and **MA** sales orders are system-generated from the MITS / Office Depot EDI pipeline (they do not originate as user-entered SA orders) and must | _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/PurchaseOrder.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php |
13
13
  | [Persona Model & Levy-Sector Gating (worker2 PEOPLE cron)](features/persona-model-and-levy-gating.md) | 2.0 | Compass USA catalogue visibility is driven by **personas** in `Client_Compass`. | worker2/Worker/Client/Compass/PeopleFile.php |
14
+ | [Compass Sales-Order Line-Number Renumbering (and why it hangs off the SO hooks)](features/sales-order-line-renumbering.md) | 2.0 | Compass sales-order lines must stay numbered **1..N with no gaps** after any add, edit, or delete — downstream MITS/PO linking reads `lineNumber` as an identity | _underscore/Model/Compass/SalesOrderItem.php, _underscore/Model/Compass/SalesOrder.php |
14
15
  | [Compass USA](profile.md) | 2.0 | Compass USA is a TOGA client running a multi-tier supply-chain commerce operation. | |
15
16
  | [Compass Cross-Kit Bundle Corruption — Detection & Repair](workflows/cross-kit-bundle-corruption.md) | 2.0 | A frontend regression in `toga2-commerce`'s edit-order bundle submission mis-attributed bundle (kit) line items and **fees/warranties** to the **wrong kit**, pe | src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts |
16
17
  | [Compass Office Depot Duplicate PO-Line Cleanup (dual-catalog SKU)](workflows/odp-duplicate-po-line-cleanup.md) | 1.0 | The NetSuite fulfillment-sales-order importer duplicated Office Depot purchase-order lines on Compass USA because it reconciled the fulfillment SO against the e | library/app/api/toga2.php, dbchanges2/Client_Compass/2026-07-22a - CleanupOfficeDepotDuplicatePurchaseOrderItems.sql |
@@ -0,0 +1,103 @@
1
+ ---
2
+ title: Compass Sales-Order Line-Number Renumbering (and why it hangs off the SO hooks)
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: compass-usa
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-08-11
10
+ owners: ["bala"]
11
+ files:
12
+ - _underscore/Model/Compass/SalesOrderItem.php
13
+ - _underscore/Model/Compass/SalesOrder.php
14
+ related:
15
+ - ../../../2.0/apps/api2/features/api-payload-interceptors.md
16
+ - ../workflows/order-lifecycle-and-data-integrity.md
17
+ - ./mits-po-to-so-item-linking.md
18
+ ---
19
+
20
+ ## Summary
21
+
22
+ Compass sales-order lines must stay numbered **1..N with no gaps** after any add, edit, or delete —
23
+ downstream MITS/PO linking reads `lineNumber` as an identity (see
24
+ [MITS PO → SO item linking](./mits-po-to-so-item-linking.md)), so a hole in the sequence is not
25
+ cosmetic. `renumberLineNumbers()` on `_Model_Compass_SalesOrderItem` does the renumbering; **where it
26
+ is hooked is the whole story**, because the obvious wiring is impossible and the second-most obvious
27
+ wiring caused a 270-order production outage.
28
+
29
+ ## How it works
30
+
31
+ `_Model_Compass_SalesOrderItem::renumberLineNumbers($salesOrderId, excludeSalesOrderItemId: …)`
32
+ rewrites the order's lines to a contiguous 1..N sequence. The `excludeSalesOrderItemId` argument
33
+ exists because on the delete path the row **still exists** when the renumberer runs.
34
+
35
+ Live production wiring (`ApiPayloadInterceptors`, Compass):
36
+
37
+ | `Core.Records` | route | phase / method | dispatches to |
38
+ |---|---|---|---|
39
+ | **14** | `sales-orders` | `POST` / `POST` | `_Model_Compass_SalesOrder::postPost()` (~L407) → `renumberLineNumbers()` |
40
+ | **14** | `sales-orders` | `POST` / `PUT` | `_Model_Compass_SalesOrder::postPut()` (~L713) → `renumberLineNumbers()` |
41
+ | **15** | `sales-order-items` | `PRE` / `DELETE` | `_Model_Compass_SalesOrderItem::preDelete()` |
42
+
43
+ Add/edit renumbering rides the **parent** (`sales-orders`) hooks; delete renumbering rides the
44
+ **child**'s `preDelete`.
45
+
46
+ ## Why delete-time renumbering CANNOT be a `postDelete`
47
+
48
+ A `postDelete` interceptor can never fire at all. api2's post-processing interceptor block runs
49
+ **only when `$outData` is non-null**, and a successful DELETE always sets `$outData = null` — so a
50
+ `(15, POST, DELETE)` row would be registered, valid-looking, and permanently dead, with no error and
51
+ no log line. This is a framework constraint, not a Compass one; full detail in
52
+ [API payload interceptors](../../../2.0/apps/api2/features/api-payload-interceptors.md).
53
+
54
+ Hence `preDelete`, and hence the `excludeSalesOrderItemId` argument: at `preDelete` time the row is
55
+ still in the table, so the renumberer must be told to skip it or it will number the row that is
56
+ about to vanish.
57
+
58
+ ## ⚠ The wiring that caused the outage — do not repeat it
59
+
60
+ An earlier attempt registered **`POST`/`POST` on `sales-order-items` (record 15)**. The engine
61
+ derives the method name by convention (`POST`+`POST` → `postPost`) and resolves the class by
62
+ swapping `_Model_Client_` for the JWT client slug, giving `_Model_Compass_Usa_SalesOrderItem` — a
63
+ class that **never had a `postPost()`**. The dispatch is unguarded, so every matching write hit:
64
+
65
+ ```
66
+ Call to undefined method _Model_Compass_Usa_SalesOrderItem::postPost()
67
+ ```
68
+
69
+ **Impact: 270 failed Compass order submissions, 2026-08-05 17:16 → 2026-08-06 07:58**, each an
70
+ opaque `EO-1` 500 with no message for the user.
71
+
72
+ **Resolution:** the row was deleted and the renumbering relocated to the `sales-orders` hooks, which
73
+ fire reliably as the request's **first-resolved record**. Verified healthy afterwards — **249
74
+ `DELETE /v2/sales-order-items/*` calls in production since 2026-08-06, all HTTP 200.**
75
+
76
+ ## Gotchas / known issues
77
+
78
+ - **⚠ Never register an interceptor row before confirming the method exists in the deployed
79
+ `_underscore`.** The dispatch has no `method_exists()` guard; a wrong row is a tenant-wide write
80
+ outage, not a no-op.
81
+ - **`(POST, DELETE)` rows are silently dead.** Delete-time logic belongs in `preDelete`.
82
+ - **`preDelete` must exclude the row being deleted** — it is still present when the hook runs.
83
+ - **Prefer the parent record's hook for order-wide invariants.** `sales-orders` `postPost`/`postPut`
84
+ fires as the request's first-resolved record and covers add/edit in one place; per-item hooks add
85
+ both the `$outData` constraint and the undefined-method risk for no benefit.
86
+ - **The method may be defined once on the shared `_Model_Compass_*` base** — `Usa` and `Canada`
87
+ subclasses inherit it. Do not add per-region copies.
88
+ - **These interceptor rows have no route and no audit trail.** They are direct SQL only; nothing
89
+ records who added or removed one. Snapshot Compass's row set into the ticket before changing it.
90
+
91
+ ## Change history
92
+
93
+ - 2026-08-11 — Documented the as-built wiring and the reasoning behind it after a production
94
+ post-mortem (investigation only, no code change): add/edit renumbering hangs off
95
+ `_Model_Compass_SalesOrder::postPost`/`postPut` (record **14** `sales-orders`), delete-time
96
+ renumbering off `_Model_Compass_SalesOrderItem::preDelete` (record **15** `sales-order-items`,
97
+ `PRE`/`DELETE`) with `excludeSalesOrderItemId` because the row still exists at that point.
98
+ Recorded that a **`postDelete` interceptor can never fire** (the post block runs only when
99
+ `$outData` is non-null and a successful DELETE nulls it), and that the earlier `POST`/`POST` row on
100
+ `sales-order-items` resolved to the nonexistent `_Model_Compass_Usa_SalesOrderItem::postPost()` and
101
+ caused **270 failed Compass order submissions between 2026-08-05 17:16 and 2026-08-06 07:58**; the
102
+ row was removed and the logic relocated. Verified healthy: 249 `DELETE /v2/sales-order-items/*` in
103
+ production since 2026-08-06, all HTTP 200. (bala)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.552",
3
+ "version": "1.0.553",
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",