toga-ai 1.0.719 → 1.0.720

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: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-29
9
+ updated: 2026-09-01
10
10
  owners: ["mhammontree", "dfranks", "bala", "jcardinal"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -16,7 +16,10 @@ files:
16
16
  related:
17
17
  - ../architecture.md
18
18
  - ./api-payload-interceptors.md
19
+ - ./v2-api-error-codes.md
20
+ - ../../_underscore/features/error-reporting-issue-event.md
19
21
  - ../../../../clients/aig/features/entitlement-intake.md
22
+ - ../../../../clients/compass-usa/features/mits-po-to-so-item-linking.md
20
23
  - ../../../../clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md
21
24
  ---
22
25
 
@@ -146,6 +149,67 @@ transaction no matter how many times apiproxy retried. That is why the retry att
146
149
  there even in principle — the two logs are not two views of the same rows. Join the two by
147
150
  `transactionId` when you need the full attempt sequence for one user action.
148
151
 
152
+ ## Getting the REAL error behind a generic `EO-1` 500 (the three-hop chain)
153
+
154
+ A 500 whose only content is `EO-1` — *"Operation Failed: An internal server error occurred"* —
155
+ names neither the failing field nor the value. Very often the true failure is a **validation error
156
+ on a NESTED sub-request** that `internalApiRequest()` issued inside the outer write, and it is fully
157
+ recorded — just in three different places. Walk them in order.
158
+
159
+ **1. Decode `error.id` → `Logs.Issue` + `Logs.Event`.** The envelope carries `error.id` shaped
160
+ `<Issue.reference>-<Event.eventNumber>` (e.g. `QT-605`). Query the **prod `Logs`** schema:
161
+
162
+ ```sql
163
+ # reference is the part BEFORE the dash — searching for the whole 'QT-605' matches NOTHING
164
+ SELECT id, reference, status, errorMessage, trace, totalOccurrences, dtLastOccurred
165
+ FROM Issue
166
+ WHERE reference = 'QT';
167
+
168
+ SELECT clientId, dtCreated, errorMessage
169
+ FROM Event
170
+ WHERE issueId = <issue.id> AND eventNumber = 605;
171
+ ```
172
+
173
+ Full semantics of the reference encoding live in
174
+ [Error Reporting — Issue/Event Capture](../../_underscore/features/error-reporting-issue-event.md).
175
+
176
+ **2. Read `Issue.trace`, not `Issue.errorMessage`.** `Issue.errorMessage` and `Event.errorMessage`
177
+ are **`varchar(255)`** and truncate, and on an api2 validation failure the cut lands **exactly where
178
+ the useful part begins** — the `identifiers` / `searchCriteria` block naming the field and the value
179
+ that matched nothing. `Issue.trace` is full text and gives the exact `file:line` chain, which is
180
+ usually enough to identify *which* nested call failed even though it never tells you *with what
181
+ value*.
182
+
183
+ **3. The complete inner error is a row in `Logs_<Client>.Api` — and you CANNOT find it by the outer
184
+ `transactionId`.** `internalApiRequest()` logs **every nested sub-request** as its own client-log
185
+ row, and since the 2026-08-25 collision fix that row's `transactionId` is a **freshly generated
186
+ random uuid** (`_String::generateUuid()`), deliberately unrelated to the outer request — see
187
+ [api2 architecture](../architecture.md). So the inner row shares **nothing** with the caller except
188
+ its timestamp. Locate it by **`dtStamp` window + `route`**, then read its `responsePayload`, which
189
+ carries the complete message including `identifiers` and `searchCriteria`.
190
+
191
+ ```sql
192
+ SELECT id, dtStamp, route, method, responseCode, LEFT(responsePayload, 2000)
193
+ FROM Logs_Compass.Api
194
+ WHERE
195
+ dtStamp BETWEEN '2026-09-01 04:12:00' AND '2026-09-01 04:12:30' AND
196
+ route LIKE '/v2/purchase-orders%'
197
+ ORDER BY dtStamp;
198
+ ```
199
+
200
+ > **⚠ Three traps in this chain**, each of which has already sent an investigation the wrong way:
201
+ > searching `Issue.reference` for the **full** `QT-605` string (it stores only `QT`); believing the
202
+ > **truncated** `errorMessage` is the whole error (it is severed at 255 chars, right before the
203
+ > field name); and trying to join the inner sub-request row by `transactionId` (it is random by
204
+ > design, so the join returns nothing and reads as "the inner call was never logged").
205
+
206
+ **Worked example.** A Compass `POST /v2/purchase-orders` returned `EO-1`/`QT-605`. `Issue.trace`
207
+ pointed at `internalApiRequest()` (`V2.php` ≈L2433); the matching `Logs_Compass.Api` row for the
208
+ nested sales-order write held the real verdict — **`EV-12` "unable to find a unique match" on field
209
+ `item`** for a part number scoped to the wrong catalog. Nothing in the 500 the caller saw contained
210
+ the word `item`. See
211
+ [Compass MITS PO → SO item linking](../../../../clients/compass-usa/features/mits-po-to-so-item-linking.md).
212
+
149
213
  ## Blind spots when auditing a client's API traffic
150
214
 
151
215
  Looking only at `Logs_<Client>.Api` misses four sources:
@@ -225,6 +289,16 @@ re-send.
225
289
 
226
290
  ## Change history
227
291
 
292
+ - 2026-09-01 — Added **Getting the REAL error behind a generic `EO-1` 500** — the three-hop chain
293
+ from an opaque 500 to the actual failing field. Key new fact: `internalApiRequest()` logs each
294
+ **nested** sub-request into `Logs_<Client>.Api` under a **randomly generated `transactionId`**
295
+ (the 2026-08-25 collision fix), so the inner row **cannot** be joined to the outer request — find
296
+ it by `dtStamp` window + `route` and read its `responsePayload`, which is the only place the
297
+ `identifiers`/`searchCriteria` survive. Also recorded that `error.id` is
298
+ `<Issue.reference>-<Event.eventNumber>` and `Issue.reference` stores only the part **before** the
299
+ dash, and that the `varchar(255)` `errorMessage` truncates exactly where the field name starts, so
300
+ `Issue.trace` is what to read. Found while root-causing the Compass MR purchase-order 500 storm
301
+ (`EV-12` on `item`, hidden behind `EO-1`). No code change. (bala)
228
302
  - 2026-08-29 — Noted that `Logs.Api.route` values are stored **with the `/v2` prefix** (e.g.
229
303
  `/v2/inventory-adjustments/{uuid}`), so narrowing queries must filter `route LIKE '/v2/...'` — a
230
304
  bare `/inventory-adjustments` predicate matches nothing. Reinforced that a narrowed row's
@@ -6,9 +6,9 @@
6
6
  | [Compass ASN → ItemFulfillment Auto-Creation](features/asn-to-item-fulfillment.md) | 2.0 | For Compass USA, posting an AdvanceShippingNotice (ASN) auto-creates the ItemFulfillment (IF) on the upstream SalesOrder. | _underscore/Model/Compass/AdvanceShippingNotice.php, _underscore/Model/Compass/PurchaseOrder.php, api2/Component/Api/Cxml/Cxml.php, dbchanges2/Client_Compass/2026-06-11 - AsnItemTrackingNumberAcl.sql, dbchanges2/Client_Compass/2026-06-15b - BackfillSA132781ItemFulfillmentTracking.sql, dbchanges2/Client_Compass/2026-06-16 - CleanupSA132763CrossLineTracking.sql, dbchanges2/Client_Compass/2026-06-16b - CleanupSA132743CrossLineTracking.sql, dbchanges2/Client_Compass/2026-06-16c - BackfillSA132763C40QYUCTracking.sql, dbchanges2/Client_Compass/2026-06-18a - CleanupSA132898DuplicateTracking.sql, dbchanges2/Client_Compass/2026-06-18b - CleanupSA132881DuplicateTracking.sql, dbchanges2/Client_Compass/2026-07-02a - FixSA133377TrackingSerialAndDuplicateIF.sql |
7
7
  | [Cost Centers — Unit Locations, numeric-only policy](features/cost-centers.md) | 2.0 | A Compass "cost center" — the value a user picks in commerce and that lands on an order — is **not** a `CostCenters` row. | toga2-commerce/src/pages/Cart/api/CartApi.ts, worker1.5/crons/toga2/compass/import_locations.php, _underscore/Model/Compass/SalesOrder.php, api2/Component/Api/V2/V2.php, dbchanges2/Client_Compass/2026-07-06 - RemoveNonNumericCostCenters.sql |
8
8
  | [Compass isFulfillable — Data Quality & the Type-Derived Rule](features/isfulfillable-data-quality-and-type-rule.md) | 2.0 | A **prod-data investigation (2026-08-12, read-only)** into why so many Compass storefront lines still have a dead **Qty Fulfilled** cell. | _underscore/Model/Client/Item.php, library/app/api/toga2.php, worker/crons/toga2/netsuite/backfill_isfulfillable_all_clients.php, dbchanges2/Core/2026-07-17 - RegisterItemIsFulfillableInterceptors.sql, toga2-supply/src/pages/Orders/view/OrderView/components/sections/OrderItemsTableSection.tsx |
9
- | [Compass Item Catalogs (Compass vs Agilant) & the Duplicate Items the NetSuite Sync Creates](features/item-catalogs-and-duplicate-items.md) | 1.0 | `Client_Compass.Items` is **split across catalogs by which pipeline created the item**, and the two halves are routinely mistaken for one. | library/app/api/toga2.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php |
9
+ | [Compass Item Catalogs (Compass vs Agilant) & the Duplicate Items the NetSuite Sync Creates](features/item-catalogs-and-duplicate-items.md) | 1.0 | `Client_Compass.Items` is **split across catalogs by which pipeline created the item**, and the two halves are routinely mistaken for one. | library/app/api/toga2.php, _underscore/Model/Compass/PurchaseOrder.php, _underscore/Model/Compass/Usa/PurchaseOrder.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php |
10
10
  | [Compass: Item-Fulfillment TableViews (for-sales-order-items & for-sales-orders, tracking via bridge)](features/item-fulfillment-tracking-tableview.md) | 2.0 | Two sibling Compass TableViews in `Client_Compass` display fulfilled items in toga2-supply, both driven by `TableViews` / `TableViewJoins` / `TableViewFields` c | dbchanges2/Client_Compass/2026-06-10 - ItemFulfillmentsForSalesOrderItemsTableView.sql, dbchanges2/Client_Compass/2026-06-11 - ItemFulfillmentsForSalesOrdersTableView.sql, dbchanges2/Client_Compass/2026-06-15a - FixItemFulfillmentTrackingNumberJoins.sql, dbchanges2/Client/2026-07-15a - ExcludeFeeItemsFromItemFulfillmentsForSalesOrdersView.sql |
11
- | [Compass MITS PO → SO Item Linking](features/mits-po-to-so-item-linking.md) | 2.0 | MITS sends Compass inbound Purchase Orders (`POST /v2/purchase-orders`) against a Sales Order (`mitsSalesOrder`). | _underscore/Model/Compass/PurchaseOrder.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php |
11
+ | [Compass MITS PO → SO Item Linking](features/mits-po-to-so-item-linking.md) | 2.0 | MITS sends Compass inbound Purchase Orders (`POST /v2/purchase-orders`) against a Sales Order (`mitsSalesOrder`). | _underscore/Model/Compass/PurchaseOrder.php, _underscore/Model/Compass/Usa/PurchaseOrder.php, api2/Component/Api/V2/V2.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php |
12
12
  | [Compass MITS PO Transmission to Vendors](features/mits-po-transmission-to-vendors.md) | 2.0 | The 1.0 worker cron `2_transmit_mits_purchase_orders_to_vendors.php` transmits Compass PurchaseOrders to their vendors (Office Depot, Strategic Systems, Compass | worker/crons/toga2/compass/workflow/2_transmit_mits_purchase_orders_to_vendors.php, worker/crons/toga2/compasscanada/workflow/2_transmit_mits_purchase_orders_to_vendors.php, library/app/client/compass.php |
13
13
  | [Compass MITS PO rejection — zero line items (400 + Issue + PM alert email)](features/mits-po-zero-line-item-rejection.md) | 2.0 | MITS transmits Compass purchase orders that carry **`"purchaseOrderItems": []`** (real example: PO `50309196-1`, `mitsSalesOrder` `MR244630`). | _underscore/Model/Compass/PurchaseOrder.php, _underscore/Model/Compass/Usa/PurchaseOrder.php, _underscore/Model/Compass/Canada/PurchaseOrder.php, worker2/Worker/Client/Compass/reports/PurchaseOrderRejectionEmail.php |
14
14
  | [Compass MITS Sales-Order Transmit Alerting (US Issue/Event vs Canada in-cron email)](features/mits-sales-order-transmission-alerting.md) | 2.0 | How a failure in the `1_transmit_..._sales_orders_to_mits.php` cron reaches a human — for **both** Compass tenants, because **they no longer work the same way** | worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php, worker/crons/toga2/compasscanada/workflow/1_transmit_compassCanada_sales_orders_to_mits.php, worker2/Worker/Infrastructure/Errors.php, tools/mvc/errors/post.php, _underscore/Model/Core/Logs/Issue.php, _underscore/Model/Core/Logs/IssueEmailAddress.php, dbchanges2/Logs/2026-08-04a - MITS rejection business recipients.sql |
@@ -10,10 +10,13 @@ updated: 2026-09-01
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - library/app/api/toga2.php
13
+ - _underscore/Model/Compass/PurchaseOrder.php
14
+ - _underscore/Model/Compass/Usa/PurchaseOrder.php
13
15
  - worker/crons/toga2/netsuite/common_sync_togasupply.php
14
16
  - worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php
15
17
  related:
16
18
  - odp-edi-850-item-resolution.md
19
+ - mits-po-to-so-item-linking.md
17
20
  - isfulfillable-data-quality-and-type-rule.md
18
21
  - ../workflows/odp-duplicate-po-line-cleanup.md
19
22
  - ../workflows/odp-order-pipeline-to-netsuite.md
@@ -30,7 +33,9 @@ two halves are routinely mistaken for one. **This is the first thing to check wh
30
33
  item "exists but is not found."** A part number that is present, active and correct in
31
34
  `Client_Compass.Items` will still fail a lookup that is scoped to the wrong catalog — which is
32
35
  exactly how the 2026-08-31 Office Depot EDI import outage happened
33
- ([ODP EDI 850 line-item resolution](odp-edi-850-item-resolution.md)).
36
+ ([ODP EDI 850 line-item resolution](odp-edi-850-item-resolution.md)) and, the same night, how every
37
+ Compass **MITS MR purchase order** for an Agilant-catalog SKU started returning an opaque HTTP 500
38
+ ([MITS PO → SO item linking](mits-po-to-so-item-linking.md)).
34
39
 
35
40
  The second half of the same problem: the NetSuite sync **creates a second `Items` row** for a
36
41
  product that already exists in the other catalog rather than matching it, so one physical product
@@ -85,14 +90,16 @@ in catalog 1 under a slightly different part number gets a **second `Items` row*
85
90
 
86
91
  Plus `-1` / `-2` suffixed copies: items **2431, 2432, 2443, 2653**.
87
92
 
88
- **Two distinct duplicate shapes exist and they fail differently:**
93
+ **Two distinct duplicate shapes, and only the first exists in prod today:**
89
94
  - **Different part numbers** (the table above) — the two rows never match on part number, so each
90
- pipeline keeps using "its own" item and quantities/fulfillments diverge.
91
- - **Same part number, two catalogs** — this is the
95
+ pipeline keeps using "its own" item and quantities/fulfillments diverge. This is the live shape.
96
+ - **Same part number, two catalogs — NOT present in prod.** This is the shape behind the
92
97
  [dual-catalog duplicate PO-line bug](../workflows/odp-duplicate-po-line-cleanup.md) (4,886
93
- duplicate lines across 1,320 Office Depot POs), and it is also why a part-number lookup that
94
- **drops** the catalog filter can silently pick the wrong sibling: verified on a local
95
- `Client_Compass` snapshot, **243** part numbers exist in more than one catalog.
98
+ duplicate PO lines across 1,320 Office Depot POs when that was investigated).
99
+ **Verified on prod `Client_Compass` 2026-09-01: zero part numbers are duplicated at all, and zero
100
+ span more than one catalog.** It matters only as a thing to re-check, because a part-number lookup
101
+ with no catalog filter has **no ambiguity check on its exact-match branch**
102
+ ([850 line-item resolution](odp-edi-850-item-resolution.md)).
96
103
 
97
104
  ## "Who changed this part number?" — the audit trail says nobody
98
105
 
@@ -115,7 +122,18 @@ children" rule:
115
122
 
116
123
  - **⚠ Never scope a Compass item lookup to one catalog** unless you have proved the item can only
117
124
  come from that source. The correct default is `Items.partNumber IN (...)` with **no** catalog
118
- condition, and then reference the resolved record **by `uuid`**.
125
+ condition, and then reference the resolved record **by `uuid`**. **The catalog filter never buys
126
+ uniqueness on Compass** — `Items.partNumber` is unique across the whole table (re-verified prod
127
+ 2026-09-01) — so every catalog condition is pure downside.
128
+ - **⚠ The 2.0 side hardcodes the OTHER catalog, and it broke MR purchase orders.**
129
+ `_Model_Compass_PurchaseOrder::handleMrOrder` builds each SO item as
130
+ `['partNumber' => …, 'catalog' => ['uuid' => static::COMPASS_CATALOG_UUID]]`, and
131
+ `_Model_Compass_Usa_PurchaseOrder::COMPASS_CATALOG_UUID` is `961dbcbc…` = **catalog 1
132
+ (Compass)**. So the 1.0 sync can only ever *write* catalog 2 and the 2.0 MR handler can only ever
133
+ *read* catalog 1 — the two hardcodes point in opposite directions. Any MR order for a catalog-2
134
+ SKU (`C40QYUC`, `B97TGUC`, `4U555A-S`, …) resolves zero rows → api2 `EV-12` → opaque `EO-1` 500
135
+ with MITS retrying forever. Details, blast radius and fix:
136
+ [MITS PO → SO item linking](mits-po-to-so-item-linking.md).
119
137
  - **⚠ A NULL `catalogId` is a real state** (27 items in prod, 22 ODP `VendorItems` point at such
120
138
  items). Anything joining through `Catalogs` **drops those rows silently** — an inner join to
121
139
  `Catalogs` is itself a filter.
@@ -127,6 +145,14 @@ children" rule:
127
145
  ([850 line-item resolution](odp-edi-850-item-resolution.md)).
128
146
 
129
147
  ## Change history
148
+ - 2026-09-01 — Recorded a **second, independent victim of the same catalog-scoping trap, on the 2.0
149
+ side**: `_Model_Compass_PurchaseOrder::handleMrOrder` references MR sales-order items with a
150
+ hardcoded `COMPASS_CATALOG_UUID` (`961dbcbc…` = catalog **1**), which is the mirror image of the
151
+ 1.0 sync's hardcoded `'Agilant'` (catalog **2**) — one only writes catalog 2, the other only reads
152
+ catalog 1. MR POs for `C40QYUC` / `B97TGUC` / `4U555A-S` therefore failed api2 `EV-12` and surfaced
153
+ as an opaque `EO-1` 500 (`Logs.Issue` reference `QT`, 660 occurrences, 10 POs blocked overnight
154
+ 2026-08-31). Also promoted "the catalog filter never buys uniqueness" into the gotchas, since
155
+ `Items.partNumber` uniqueness across the whole table was re-verified on prod the same day. (bala)
130
156
  - 2026-09-01 — Documented the `Client_Compass` catalog split as its own subject after it caused the
131
157
  2026-08-31 ODP EDI import outage: catalogs 1 "Compass" (Etilize, `c_etilizeProductId`) / 2
132
158
  "Agilant" (1.0 NetSuite sync, `c_netsuiteInternalItemId`) / 3 with their uuids and prod counts
@@ -135,8 +161,10 @@ children" rule:
135
161
  `syncItemFulfillmentFromNetsuite()`, `syncInventoryAdjustmentFromNetsuite()` and the
136
162
  Agilant-only lookup built in `common_sync_togasupply.php` — which is **why** the sync creates a
137
163
  duplicate instead of matching. Listed the observed duplicate pairs (1815/2839, 2216/2427,
138
- 2225/2430, plus 2431/2432/2443/2653) and separated the two duplicate shapes (different part
139
- numbers vs. same part number in two catalogs, 243 such part numbers on a local snapshot).
164
+ 2225/2430, plus 2431/2432/2443/2653) and separated the two duplicate shapes: **different** part
165
+ numbers is the live shape, while **same** part number in two catalogs is **not present in prod**
166
+ (verified on prod 2026-09-01 — zero duplicated part numbers, zero spanning two catalogs), so it
167
+ is a latent risk only.
140
168
  Recorded the audit-trail method (`Logs_Compass.Record` ⋈ `RecordField`, `recordId` 21 = Items,
141
169
  `recordFieldId` 108 = partNumber) and its verdict: every `partNumber` row is a create with a NULL
142
170
  `oldValue` by `apiId = 1`, so **nobody renamed anything**. Data/investigation finding, no code
@@ -6,15 +6,20 @@ project: _Underscore
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-13
9
+ updated: 2026-09-01
10
10
  owners: ["jcardinal", "rgirish", "dfranks", "bala"]
11
11
  files:
12
12
  - _underscore/Model/Compass/PurchaseOrder.php
13
+ - _underscore/Model/Compass/Usa/PurchaseOrder.php
14
+ - api2/Component/Api/V2/V2.php
13
15
  - worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php
14
16
  related:
15
17
  - asn-to-item-fulfillment.md
16
18
  - mits-po-transmission-to-vendors.md
17
19
  - mits-po-zero-line-item-rejection.md
20
+ - item-catalogs-and-duplicate-items.md
21
+ - ../../../2.0/apps/api2/features/request-logging.md
22
+ - ../../../2.0/apps/api2/features/v2-api-error-codes.md
18
23
  ---
19
24
 
20
25
  ## Summary
@@ -109,7 +114,9 @@ per-api renames and interceptor scoping interact.
109
114
  returns early. `handleMrOrder` creates/appends the SO from the PO items, **strips**
110
115
  `createdFromSalesOrderItem`, and deliberately **aligns** SO and PO `lineNumber`s. It does
111
116
  NOT build the item bridge — so the `postPost` lineNumber-match pass is the **only** (and
112
- correct) MR linking mechanism.
117
+ correct) MR linking mechanism. Each SO item it builds references the item as
118
+ `partNumber` **plus a hardcoded catalog uuid**, which is the cause of the MR 500 outage below —
119
+ see [MR POs 500 when the item is outside the Compass catalog](#-mr-pos-500-when-the-item-lives-outside-the-compass-catalog-open-as-of-2026-09-01).
113
120
  - **MA orders — item population in the ODP PO import cron:** the 3a cron
114
121
  (`worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php`) parses ODP
115
122
  EDI 850 POs. Its "Non-SA order handling" block creates the Compass **MA** sales order. Line
@@ -140,6 +147,57 @@ per-api renames and interceptor scoping interact.
140
147
  USA and Canada share the parent handler unchanged.
141
148
 
142
149
  ## Gotchas / known issues
150
+ ### ⚠ MR POs 500 when the item lives outside the Compass catalog (OPEN as of 2026-09-01)
151
+
152
+ **Symptom.** `POST /v2/purchase-orders` on `api-writer.togahub.com` returns **HTTP 500** with the
153
+ generic envelope error **`EO-1`** ("Operation Failed: An internal server error occurred"). MITS has
154
+ no backoff, so it **retries every few seconds forever** — 66 attempts were observed for a single PO.
155
+ Sibling POs on the *same* MR sales order succeed, which makes it look intermittent. It is not: it is
156
+ **per part number**.
157
+
158
+ **Cause.** `_Model_Compass_PurchaseOrder::handleMrOrder` (≈L61) builds every `salesOrderItems`
159
+ item reference **scoped to one catalog**:
160
+
161
+ ```php
162
+ 'item' => [
163
+ 'partNumber' => $partNumber,
164
+ 'catalog' => ['uuid' => static::COMPASS_CATALOG_UUID],
165
+ ]
166
+ ```
167
+
168
+ `COMPASS_CATALOG_UUID` on `_Model_Compass_Usa_PurchaseOrder` is
169
+ `961dbcbc-1943-11ef-8e3b-0aae2980db55` = **`Client_Compass.Catalogs` id 1 ("Compass")**. But
170
+ `Client_Compass` has **three** catalogs (1 Compass, 2 Agilant, 3 Office Depot) and a large set of
171
+ MITS part numbers are `Items` rows under **catalog 2 (Agilant)** — observed: `C40QYUC`, `C40QYUC-1`,
172
+ `B97TGUC`, `B97TGUC-1`, `4U555A-S`. The catalog-scoped reference resolves **zero** rows, api2 raises
173
+ **`EV-12`** ("unable to find a unique match … requires an exact match for locating the child
174
+ record") on field `item`, `internalApiRequest` (`api2/Component/Api/V2/V2.php` ≈L2433) throws, and
175
+ the **whole outer purchase-order request rolls back as a 500**. The precise field and value never
176
+ reach the caller — only `EO-1` — which is why this reads as an unexplained platform fault.
177
+
178
+ **The catalog filter buys nothing.** `Client_Compass.Items.partNumber` is **unique across the entire
179
+ table** (verified on prod 2026-09-01 — zero duplicated part numbers, zero spanning two catalogs), so
180
+ scoping the lookup to one catalog adds **no** uniqueness and only manufactures false misses. This is
181
+ the same trap that caused the 2026-08-31 ODP EDI import outage — see
182
+ [Compass Item Catalogs](item-catalogs-and-duplicate-items.md).
183
+
184
+ **Fix (not yet applied).** Either drop the `catalog` key so the lookup is by `partNumber` alone, or
185
+ resolve the item **uuid** first with an internal `GET /items` and pass `['uuid' => …]`, throwing a
186
+ clear `_Exception_Business` when the part number matches nothing. The second shape is preferable: it
187
+ turns an opaque 500-and-retry-forever into a named business rejection.
188
+
189
+ **Blast radius observed (2026-08-31 23:00 → 2026-09-01).** `Logs.Issue` reference **`QT`**
190
+ (issue id 602) accumulated **660** occurrences. **Ten** Compass MITS POs were blocked — `50312113-1`,
191
+ `50312116-1`, `50312117-1`, `50312119-1`, `50312122-1`, `50312144-1`, `50312161-1`, `50312176-1`,
192
+ `50312180-1`, `50312182-1` (MR245464/65/66/69/70/72/73/76/79/80). All ten were re-submitted
193
+ successfully on 2026-09-01 between 11:24 and 11:43 once the items existed where the lookup looked.
194
+ Sibling POs on the same MR sales orders (`50312114-1`, `50312120-1`, …) went through fine because
195
+ their item `9X3V1UT` sits in catalog 1.
196
+
197
+ **Recurrence risk is high, not theoretical.** Any Compass MR order for an Agilant-catalog SKU hits
198
+ it — custom Azure-tenant SKUs and kitting SKUs land in catalog 2 by construction, because the 1.0
199
+ NetSuite sync can only create there.
200
+
143
201
  - **SQL injection in `prePost` (OPEN as of 2026-08-03).** `_underscore/Model/Compass/PurchaseOrder.php`
144
202
  **L232** interpolates the raw client payload straight into SQL:
145
203
  `WHERE SalesOrders.number LIKE '{$payload->mitsSalesOrder}'` — no escape, no cast. The **same
@@ -227,6 +285,16 @@ USA and Canada share the parent handler unchanged.
227
285
  need item backfill from the sibling ODP SO first).
228
286
 
229
287
  ## Change history
288
+ - 2026-09-01 — **Root-caused the MR PO `EO-1` 500 storm** (2026-08-31 23:00 → 2026-09-01,
289
+ `Logs.Issue` reference `QT`, issue id 602, 660 occurrences, 10 POs blocked, MITS retrying every few
290
+ seconds with no backoff). `handleMrOrder` (≈L61) references each SO item as `partNumber` **plus**
291
+ `static::COMPASS_CATALOG_UUID` (`961dbcbc…` = `Client_Compass.Catalogs` id 1 "Compass"), but
292
+ several MITS part numbers (`C40QYUC`, `C40QYUC-1`, `B97TGUC`, `B97TGUC-1`, `4U555A-S`) are catalog-2
293
+ **Agilant** items, so the scoped lookup matches nothing → api2 `EV-12` on field `item` →
294
+ `internalApiRequest` throws → the outer PO write rolls back as an opaque 500. The catalog filter
295
+ adds no uniqueness (`Items.partNumber` is unique across the whole table, verified on prod), so the
296
+ fix is to drop the `catalog` key or resolve the item uuid first and fail with a business
297
+ exception. **No code change yet — the ten POs were re-submitted manually.** (bala)
230
298
  - 2026-08-13 — The zero-line-item rejection this doc's caller analysis was written to scope **has
231
299
  shipped** as the first statement of `prePost` (TRUE-80412), so `handleMrOrder` can no longer create
232
300
  a zero-item Sales Order for an MR PO. It is MITS-only by construction (`apiId = 2` interceptor rows
@@ -119,17 +119,19 @@ different one than the EDI importer posts against. Full catalog model, counts, a
119
119
  duplicate-item problem behind it:
120
120
  [Compass item catalogs & duplicate items](item-catalogs-and-duplicate-items.md).
121
121
 
122
- ### ⚠ Dropping the catalog filter widened the ambiguity the resolver can see
123
- Two consequences the code does **not** handle:
124
-
125
- - **The exact-match branch has no ambiguity check.** `fetchPartNumbersCompassCarries()` builds
126
- `$lookup[strtoupper($partNumber)] = ['partNumber' => …, 'uuid' => …]` — one entry per part
127
- number — so when the **same part number exists in more than one catalog**, the **last row api2
128
- returns wins** and its uuid is used, silently. (Verified on a local `Client_Compass` snapshot:
129
- **243** part numbers exist in more than one catalog; the exact prod count differs, the shape does
130
- not. This is the same dual-catalog SKU shape as the
131
- [duplicate PO-line bug](../workflows/odp-duplicate-po-line-cleanup.md).) If an ODP line ever
132
- resolves to the wrong sibling item, this is the place to look.
122
+ ### Two things the resolver does not check now that the catalog filter is out
123
+ Neither is a live problem — the first is latent, the second is safe by design:
124
+
125
+ - **The exact-match branch has no ambiguity check — latent, cannot fire today.**
126
+ `fetchPartNumbersCompassCarries()` keys its lookup **one entry per uppercased part number**
127
+ (`$lookup[strtoupper($partNumber)] = ['partNumber' => …, 'uuid' => …]`), so **if** the same part
128
+ number were ever created in two catalogs, this branch would silently take whichever row api2
129
+ returned **last** and use its uuid — unlike the vendor-SKU branch below, which counts candidates
130
+ and rejects an ambiguous match.
131
+ **Verified on prod `Client_Compass` 2026-09-01: zero part numbers are duplicated at all, and zero
132
+ span more than one catalog**, so the branch cannot pick a wrong sibling today. Re-check this if
133
+ duplicate part numbers ever reappear — the shape that would produce them is the
134
+ [dual-catalog duplicate PO-line bug](../workflows/odp-duplicate-po-line-cleanup.md).
133
135
  - **Only the vendor-SKU branch counts candidates.** `count($vendorSkuMatches) == 1` still gates
134
136
  step 2, so an ambiguous SKU is **reported, never guessed** — picking one silently would ship the
135
137
  wrong item. Recorded ambiguous SKUs: `7128204` (`AW5M5UT` / `AW5M5UT-1`), `7423324`
@@ -269,8 +271,9 @@ internal ids **7437998, 7437898, 7438098**).
269
271
  `partNumber` + `catalog` 400s **`EV-12`** when that pair is not unique (send the `uuid`
270
272
  instead); a `where` with a **single** condition still needs the `'and' =>` wrapper or api2
271
273
  returns **500 `EO-1`** with an empty `transactionId` (this cost a deploy cycle); and dropping the
272
- catalog filter means the **exact-match branch now silently takes the last row** when one part
273
- number exists in two catalogs, because that branch counts no candidates. Corrected the
274
+ catalog filter leaves the **exact-match branch without an ambiguity check** — latent only, since
275
+ prod has **zero** duplicated part numbers and **zero** spanning two catalogs (verified on prod
276
+ 2026-09-01), unlike the vendor-SKU branch which counts candidates. Corrected the
274
277
  "nothing is written" claim — the failing runs left zero-item ODP SOs `481137047001` /
275
278
  `481143381001` despite the email saying nothing was created. Verified end to end on prod (all
276
279
  three POs imported and reached NetSuite). (bala)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.719",
3
+ "version": "1.0.720",
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",