toga-ai 1.0.552 → 1.0.554

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
@@ -47,7 +47,47 @@ controller.
47
47
  - **Pagination is `page` + `recordsPerPage`. There is no `limit`/`offset`.** Defaults:
48
48
  `recordsPerPage` **25**, `depth` **3**. Max `recordsPerPage` **10000** — exceeding it returns
49
49
  **`EV-13`** (HTTP 400).
50
- - **Sort:** `-field` = DESC, bare `field` = ASC. **Do not send `+`** for ascending.
50
+ - **Sort:** `-field` = DESC, `+field` = ASC, bare `field` = ASC. All three are handled
51
+ (`V2.php` ~L3673–3682). **Prefer bare for ascending:** the query string is parsed *without*
52
+ urldecoding (see Encoding rules), so a client that percent-encodes the `+` as `%2B` sends a
53
+ field literally named `%2Bfield` and the sort is silently dropped. Bare has no such failure mode.
54
+ - **A LIST select is `SELECT SQL_CALC_FOUND_ROWS DISTINCT <table>.<primaryKey>`** (`V2.php:3884`;
55
+ the aggregate-fields path is the one exception). Two consequences: (a) join fan-out collapses to
56
+ one row per record, so **duplicate child rows are invisible in the response** — a duplicate join
57
+ row is not a rendering bug you can see; (b) when the collapsed duplicates carry **different**
58
+ values in the sorted column, `ORDER BY` has more than one candidate value for a single output row
59
+ and that record's **position is non-deterministic**. Sorting on a joined column is only stable if
60
+ the join is genuinely 1:1, or the duplicates agree. Worked example:
61
+ [category tile sort order](../../toga2-commerce/features/category-tile-sort-order.md).
62
+
63
+ ### ⚠ `depth` is not free — the default 3 re-reads child rows and races concurrent deletes
64
+
65
+ **`depth` defaults to 3 on every request, writes included.** Serialization (`getFullModelData()` in
66
+ `api2/Component/Api/V2/V2.php`) walks the relationship graph to that depth and **re-loads each child
67
+ by primary key** through `_Model::initialize()` (`_underscore/Model.php` ~L705). `_Model`'s build
68
+ **throws when the row is gone**:
69
+
70
+ ```
71
+ Error during Model build for primary key id 'NNNNNN' for '_Model_<Client>_SalesOrderItem'.
72
+ Exactly 1 row was expected to be returned but 0 were.
73
+ ```
74
+
75
+ Nothing catches it, so it surfaces as an **opaque `EO-1` HTTP 500** on a request that did nothing
76
+ wrong. (This is a *different* path from the dangling-FK tolerance in
77
+ [cross-client data retrieval](cross-client-data-retrieval.md), which nulls a failed **FK expansion**
78
+ — the child-collection re-load is not covered by it.)
79
+
80
+ **The rule for callers:** if another request may be deleting rows in a collection concurrently, a
81
+ response that walks that collection can 500. Two defences, both cheap:
82
+
83
+ - **Send an explicit `depth` — do not inherit the default.** A write that only needs its own row
84
+ should send `depth: 1`; that alone stops the serializer from touching the child collection.
85
+ - **Serialize the writes.** Do not run a request that deletes children in parallel with one whose
86
+ response walks those children. Worked example (a real production 500 burst):
87
+ [TOGa Commerce order-submit sync sequencing](../../toga2-commerce/features/order-submit-sync-sequencing.md).
88
+
89
+ The fragility itself is **unfixed** — any depth ≥ 2 response is exposed. Lowering depth removes
90
+ *your* exposure, not the defect.
51
91
 
52
92
  ### CRITICAL — an unrecognized param silently flips LIST → READ
53
93
 
@@ -129,6 +169,24 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
129
169
  invisible; re-request with `fields=` to force `EZ-2` and see the denied list.
130
170
 
131
171
  ## Change history
172
+ - 2026-08-11 — **Corrected the sort contract and documented the `DISTINCT` LIST select.** The old
173
+ "do not send `+`" was wrong as an absolute: `V2.php` ~L3673–3682 explicitly handles `+` as ASC.
174
+ The real caveat is encoding — the query string is parsed without urldecoding, so a
175
+ percent-encoded `+` (`%2B`) becomes part of the field name and the sort is silently dropped;
176
+ bare remains the recommended ascending form. Also recorded that a LIST select is
177
+ `SELECT SQL_CALC_FOUND_ROWS DISTINCT <table>.<primaryKey>` (`V2.php:3884`), which hides duplicate
178
+ join rows entirely **and** makes `ORDER BY` non-deterministic for a record whose collapsed
179
+ duplicates disagree on the sorted column. Found while diagnosing storefront category tile
180
+ ordering (duplicate `AssortmentItems` rows for one item). (bala)
181
+ - 2026-08-11 — Recorded that **`depth` (default 3) makes a response race concurrent deletes**:
182
+ `getFullModelData()` re-loads each child by primary key via `_Model::initialize()`, whose build
183
+ **throws** when the row is gone (*"Exactly 1 row was expected to be returned but 0 were"*),
184
+ uncaught → opaque `EO-1` 500. Confirmed in production from a `PUT` at default depth walking
185
+ `salesOrder → salesOrderItems` while a concurrent `DELETE /v2/sales-order-items/*` burst removed
186
+ those exact rows. Defences for callers: send an explicit `depth: 1` on writes that need only their
187
+ own row, and never run a child-deleting request in parallel with one whose response walks that
188
+ collection. Noted this is a **different** path from the dangling-FK tolerance (which nulls a failed
189
+ FK expansion) and that the underlying fragility is unfixed for any depth ≥ 2. (bala)
132
190
  - 2026-08-10 — Recorded that **`fields=` narrows the response but not the SQL SELECT**: `_Model`
133
191
  selects every property declared on the class **and its traits**, so a declared-but-missing column
134
192
  500s (`EO-1`, MySQL 1054) any request touching that record — including one that named only an
@@ -6,9 +6,11 @@
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
+ | [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 |
9
10
  | [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 |
10
11
  | [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
12
  | [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
13
  | [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
+ | [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
15
  | [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
16
  | [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,164 @@
1
+ ---
2
+ title: Category Tile Order (AssortmentItems.sortOrder) — merchandising a storefront category
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
+ - src/pages/Filter/api/FilterApi.ts
13
+ - src/pages/Filter/viewModel/useFilterViewModel.ts
14
+ - api2/Component/Api/V2/V2.php
15
+ - toga2-supply/src/pages/Items/api/itemsApi.ts
16
+ related:
17
+ - filter-search-results-page.md
18
+ - ../architecture.md
19
+ - ../../api2/features/v2-rest-query-contract.md
20
+ ---
21
+
22
+ ## Summary
23
+
24
+ **"Move item X to the front of category Y" is a DATA change, not a code change.** The order of
25
+ item tiles on a storefront category page is driven by exactly one column —
26
+ **`Client_<Client>.AssortmentItems.sortOrder`, ascending** — and nothing else. There is no
27
+ merchandising UI, no per-tenant config key, and no client-side re-sort to override.
28
+
29
+ The trap is that the obvious one-row `UPDATE` **does not work**: in MySQL `ORDER BY … ASC`,
30
+ `NULL` sorts **before** every number, and these columns are normally all `NULL`. Setting only the
31
+ target item to `1` leaves the still-`NULL` siblings ahead of it — the opposite of the request. You
32
+ must give **every active item in that assortment** an explicit `sortOrder`.
33
+
34
+ ## How it works
35
+
36
+ 1. **Category browse** is the `/filter` page with a `category` query param and no `search` text.
37
+ `useFilterViewModel.ts` runs the `["assortmentItems", categoryUuid, currentPage]` query, which
38
+ calls `fetchAssortmentItems()` in `src/pages/Filter/api/FilterApi.ts`.
39
+ 2. That request joins `Items → AssortmentItems → Assortments` (plus `Personas_Items → Personas`
40
+ for non-admins), filters to the requested assortment uuid with `Items.isActive = 1` and
41
+ `Items.catalogId = 1`, pages at `recordsPerPage: 12`, and sorts with exactly:
42
+
43
+ ```ts
44
+ sort: ["AssortmentItems.sortOrder"]
45
+ ```
46
+
47
+ 3. **api2 treats a bare (unprefixed) sort field as ASC.** (`-` = DESC, `+` = ASC — see
48
+ [v2-rest-query-contract](../../api2/features/v2-rest-query-contract.md).)
49
+ 4. **The frontend never re-sorts.** The view model does a straight
50
+ `setSortedItems(assortmentItems.data.items)` — despite the `sortedItems` name, no sorting
51
+ happens there. **The API's row order is the final on-screen order.**
52
+
53
+ So the only lever is the column. Ties (equal or all-`NULL` values) fall back to whatever MySQL
54
+ returns, which is not a documented or stable order.
55
+
56
+ ## Making the change (the procedure)
57
+
58
+ Assign an explicit `sortOrder` to **every active item** in the assortment — the two pinned ones,
59
+ then the rest. Pattern (`<assortmentId>` from `Assortments`, item ids from `Items`):
60
+
61
+ ```sql
62
+ # 1. pin the intended leaders
63
+ UPDATE AssortmentItems
64
+ SET sortOrder = 1
65
+ WHERE
66
+ assortmentId = <assortmentId> AND
67
+ itemId = <firstItemId>;
68
+
69
+ UPDATE AssortmentItems
70
+ SET sortOrder = 2
71
+ WHERE
72
+ assortmentId = <assortmentId> AND
73
+ itemId = <secondItemId>;
74
+
75
+ # 2. every remaining ACTIVE item must get a value, or a NULL row jumps to the front
76
+ UPDATE AssortmentItems ai
77
+ INNER JOIN Items i ON i.id = ai.itemId
78
+ SET ai.sortOrder = 10
79
+ WHERE
80
+ ai.assortmentId = <assortmentId> AND
81
+ ai.sortOrder IS NULL AND
82
+ i.isActive = 1;
83
+ ```
84
+
85
+ Verify with the read the storefront actually performs — ordered, not just spot-checked:
86
+
87
+ ```sql
88
+ SELECT
89
+ ai.id,
90
+ ai.itemId,
91
+ ai.sortOrder,
92
+ i.itemNumber,
93
+ i.isActive
94
+ FROM AssortmentItems ai
95
+ INNER JOIN Items i ON i.id = ai.itemId
96
+ WHERE ai.assortmentId = <assortmentId>
97
+ ORDER BY ai.sortOrder ASC;
98
+ ```
99
+
100
+ **Recommended, not required:** park **inactive** rows at a high value (e.g. `100`) as well. They
101
+ are filtered out today by `Items.isActive = 1`, so they cost nothing now — but if someone
102
+ reactivates one later it comes back with `sortOrder = NULL` and silently takes first position.
103
+
104
+ ## Gotchas
105
+
106
+ - **`NULL` sorts FIRST in ASC.** The single-row `UPDATE` that "obviously" pins an item produces the
107
+ opposite result while looking correct in isolation. Always set the whole active set.
108
+ - **Duplicate `AssortmentItems` rows exist and make order non-deterministic.** api2's LIST query
109
+ selects `SELECT SQL_CALC_FOUND_ROWS DISTINCT <table>.<primaryKey>` (`V2.php:3884`), so two
110
+ `AssortmentItems` rows for the same `itemId` collapse to **one visible tile** — which is why
111
+ nobody notices the duplicate. But if the duplicates carry **different** `sortOrder` values, the
112
+ `ORDER BY` has two candidate values for one output row and the position is unpredictable. When
113
+ duplicates exist, give them the **same** value or delete the extras. Check first:
114
+
115
+ ```sql
116
+ SELECT itemId, COUNT(*) AS rowCount
117
+ FROM AssortmentItems
118
+ WHERE assortmentId = <assortmentId>
119
+ GROUP BY itemId
120
+ HAVING rowCount > 1;
121
+ ```
122
+
123
+ - **⚠ Editing an item's assortments in TOGa Supply WIPES `sortOrder`.** See Known open items —
124
+ any ordering you set is one supply edit away from being erased. Re-verify order after anyone
125
+ edits that item in supply.
126
+ - **A refresh will NOT show the change.** The storefront persists its whole React Query cache to
127
+ `localStorage["commerce"]` with a 24h `staleTime`, so a warm browser replays the cached
128
+ response and the (correct) data fix looks like it failed — then gets "re-fixed". The reliable
129
+ check is **log out and back in** (logout removes the `commerce` key), or
130
+ `localStorage.removeItem('commerce')`; otherwise it appears within 24h. See
131
+ [architecture](../architecture.md) gotchas.
132
+ - **This is data, so it does not ride a deploy.** No build, no release notes — which also means no
133
+ audit trail. Record the assortment id and the values you set in the ticket.
134
+
135
+ ## Known open items
136
+
137
+ - **⚠ TOGa Supply resets `sortOrder` to `NULL` on any item-assortment edit — unfixed, no ticket
138
+ filed yet.** In `toga2-supply/src/pages/Items/api/itemsApi.ts`, `updateItemAssortments()`
139
+ (L645) reads the item's existing rows via `getAssortmentItems()` (L434), which requests only
140
+ `["assortment.name", "assortment.uuid", "uuid"]` — **never `sortOrder`** — then `DELETE`s every
141
+ row and `POST`s new ones whose payload carries only `item.uuid` + `assortment.uuid`. The
142
+ merchandising order is therefore silently destroyed and cannot be restored, because the old
143
+ values were never read. A fix must select `sortOrder`, and either preserve it on the re-POST or
144
+ stop the delete-then-recreate in favor of diffing.
145
+ - **Same function: the re-POSTs are never awaited.** `updateItemAssortments()` builds
146
+ `addPromises` and returns without `await Promise.all(addPromises)` (the `DELETE`s above it *are*
147
+ awaited). Failures surface as unhandled rejections and the caller cannot know the writes landed.
148
+ Fire-and-forget `POST`s racing an awaited `DELETE` burst is also the most likely origin of the
149
+ duplicate rows described in Gotchas. (Related: parallel deletes on a collection can 500 the
150
+ response of a concurrent read — see
151
+ [v2-rest-query-contract](../../api2/features/v2-rest-query-contract.md).)
152
+ - **Compass USA "Docking Station" reorder — SQL delivered, NOT executed.** See
153
+ [Compass USA profile](../../../clients/compass-usa/profile.md).
154
+
155
+ ## Change history
156
+ - 2026-08-11 — Initial doc, from a read-only prod investigation of a Compass USA request to move
157
+ one item to the front of a category. Recorded that tile order comes solely from
158
+ `AssortmentItems.sortOrder ASC` (`FilterApi.ts:56`) with no client-side re-sort in
159
+ `useFilterViewModel.ts`; the **`NULL`-sorts-first** trap that makes a single-row `UPDATE`
160
+ backfire (the real assortment had `sortOrder = NULL` on all 16 rows); api2's
161
+ `DISTINCT <primaryKey>` LIST select (`V2.php:3884`) hiding duplicate join rows while making
162
+ `ORDER BY` non-deterministic when duplicates disagree; and that toga2-supply's
163
+ `updateItemAssortments()` wipes `sortOrder` (and never awaits its re-POSTs), making any fix
164
+ non-durable. (bala)
@@ -7,7 +7,7 @@ client: shared
7
7
  type: feature
8
8
  status: active
9
9
  updated: 2026-07-28
10
- owners: ["tcox"]
10
+ owners: ["tcox", "bala"]
11
11
  files:
12
12
  - src/pages/Filter/FilterPage.tsx
13
13
  - src/pages/Filter/viewModel/useFilterViewModel.ts
@@ -24,6 +24,7 @@ related:
24
24
  - 2.0/apps/toga2-commerce/architecture.md
25
25
  - 2.0/apps/toga2-commerce/features/client-fields.md
26
26
  - 2.0/apps/toga2-commerce/features/multi-tenant-theming.md
27
+ - 2.0/apps/toga2-commerce/features/category-tile-sort-order.md
27
28
  ---
28
29
 
29
30
  ## Summary
@@ -53,6 +54,10 @@ bug (see Change history).
53
54
  tenant × language × role (see [client-fields](client-fields.md) for the resolver). `FilterPage`
54
55
  `renderBundles` maps each result to a `BundleViewCard`.
55
56
 
57
+ > **Third mode, same page:** with a `category` param and **no** `search` text, `/filter` is the
58
+ > category *browse* page and runs a different query (`fetchAssortmentItems`) whose row order is
59
+ > data-driven — see [category tile sort order](category-tile-sort-order.md).
60
+
56
61
  ### Home search → in-place filter
57
62
 
58
63
  The Home page's own lower search bar never leaves `/home`. `BundlesSection.tsx` reads
@@ -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) — 13 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)
@@ -17,7 +17,7 @@ project: _Underscore
17
17
  client: compass-usa
18
18
  type: profile
19
19
  status: active
20
- updated: 2026-08-06
20
+ updated: 2026-08-11
21
21
  owners: [jcardinal, bala, tcox, apeterson, dfranks]
22
22
  files: []
23
23
  related:
@@ -32,6 +32,7 @@ related:
32
32
  - workflows/odp-duplicate-po-line-cleanup.md
33
33
  - ../../2.0/apps/worker2/features/compass-vip-support-importer.md
34
34
  - ../../2.0/apps/toga2-commerce/features/expedited-shipping-gating.md
35
+ - ../../2.0/apps/toga2-commerce/features/category-tile-sort-order.md
35
36
  - ../../2.0/apps/toga2-commerce/features/cart-bundle-submission-and-identity.md
36
37
  - ../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
37
38
  ---
@@ -61,6 +62,17 @@ separate, related client (see its own profile).
61
62
  **Known data issue:** many Compass MacBooks are categorized `APPLE LAPTOP` /
62
63
  `MAC & ACCESSORIES`, not `COMPUTERS`, so those kits do **not** qualify for expedited — a
63
64
  catalog-data normalization matter, not a code gap.
65
+ - **Category tile order is data, not code** — `Client_Compass.AssortmentItems.sortOrder ASC` is the
66
+ only lever; see
67
+ [Category Tile Order](../../2.0/apps/toga2-commerce/features/category-tile-sort-order.md) for the
68
+ `NULL`-sorts-first trap and the procedure.
69
+ **⚠ Pending prod data change (2026-08-11, delivered but NOT executed):** for
70
+ `Assortments.id = 9` ("Docking Station", all 16 rows had `sortOrder = NULL`), pin
71
+ `itemId 2712` / `9X3V1UT` → `1` and `itemId 2812` / `AW5M5UT-1` → `2`, and give the remaining
72
+ active rows an explicit value. Handed off for a DBA / the TOGa UI to run because session DB
73
+ access was read-only. Also outstanding for that assortment: park the 13 inactive rows at a high
74
+ `sortOrder`, and remove the duplicate `AssortmentItems` row `id 2982` (duplicate of `2981`, same
75
+ `itemId 2712`). Confirm whether this was applied before re-diagnosing tile order for Compass.
64
76
  - **Cross-kit bundle corruption (edit-order):** a `toga2-commerce` submit bug attributed kit
65
77
  line items and shared fees/warranties to the wrong kit; 55 Compass orders / 298 line items are
66
78
  corrupted in `Client_Compass.SalesOrderItems` (Canada and Quad: zero). The code fix is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.552",
3
+ "version": "1.0.554",
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",