toga-ai 1.0.809 → 1.0.811

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: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-14
9
+ updated: 2026-09-15
10
10
  owners: ["dfranks", "bala", "jcardinal", "mhammontree", "snaredla", "rgirish"]
11
11
  files:
12
12
  - worker/crons/toga2/netsuite/common_sync_togasupply.php
@@ -1270,6 +1270,50 @@ library (or vice versa) crashes GroWrk and Adyen on their next sync run.
1270
1270
  Note `SalesOrderItems.lineNumber` is a **`smallint`**, so the parked base must stay within smallint
1271
1271
  range for the client's line counts (headroom 1000 is safe at observed volumes).
1272
1272
 
1273
+ - **⚠ A NetSuite-removed line that still holds an UPSTREAM customer-PO link blocked the renumber and
1274
+ re-froze Compass SALES_ORDERS (fixed 2026-09-15, `library` only, Compass-gated).** Compass USA's
1275
+ SALES_ORDERS was stuck at `1-RUNNING` (`Logs.Issue` 749, clientId 2) throwing *"unresolvable
1276
+ NetSuite<->DB lineNumber conflict … reconcile the orphaned/undeletable row"* whenever a
1277
+ NetSuite-removed line sat on a `lineNumber` a surviving line had to move onto. Root cause: the
1278
+ **existing** stale-line prune (`toga2.php` ~L1582) runs **AFTER** the renumber **and** skips any
1279
+ line carrying a PO link (it cannot delete a PO-linked line), so those removed-but-PO-linked lines
1280
+ survived and occupied a needed target number — and the renumber threw first anyway.
1281
+ - **Fix — a Compass-gated prune BEFORE the renumber** in `syncSalesOrderFromNetsuite`. For each SO
1282
+ line whose `c_netsuiteLineUniqueKey` is **non-empty** and **absent** from the current NetSuite
1283
+ line set (a line NetSuite removed) that was **never fulfilled and never invoiced**: delete its
1284
+ Agilant-side SO↔PO **bridge LINK** rows in **both** directions (routes
1285
+ `/purchase-order-item-sales-order-items/{uuid}` and `/sales-order-item-purchase-order-items/{uuid}`),
1286
+ then DELETE the `SalesOrderItem` (`/sales-order-items/{uuid}`), then drop it from the in-memory
1287
+ `$salesOrderItemsInToga`. It **NEVER** deletes/modifies the `PurchaseOrder`/`PurchaseOrderItem`
1288
+ itself (the Compass **customer** order is off-limits) and never touches another order record.
1289
+ - **Guardrails (owner decision 2026-09-15; cto AGREE; php-reviewer clean; cso SAFE TO SHIP):**
1290
+ (a) non-empty key **absent from NetSuite** only — an empty key was never matched to NetSuite, so
1291
+ treating it as "removed" is a false positive; (b) never a shipped/invoiced line (checked against
1292
+ the fulfillment/invoice maps built ~L990-1032); (c) **Compass-gated** (`CLIENT_UUID_COMPASS`);
1293
+ (d) **re-check that one line's fulfillment/invoice status immediately before the deletes** to
1294
+ close a TOCTOU gap (the maps are a start-of-run snapshot); (e) the final `SalesOrderItem` DELETE
1295
+ stays **throwing** (fail-loud) if any unhandled attachment remains — mirrors the PO-line cleanup
1296
+ in `syncPurchaseOrderFromNetsuite` (~L2793-2806) and the mirror-NetSuite decision (2026-09-02).
1297
+ - **Every FK child of `Client_Compass.SalesOrderItems` blocks a delete.** All are `NO ACTION` or
1298
+ `RESTRICT`, and InnoDB treats `NO ACTION` as `RESTRICT`: `ItemFulfillmentItems`, `InvoiceItems`,
1299
+ `PurchaseOrderItems_SalesOrderItems`, `SalesOrderItems_PurchaseOrderItems`,
1300
+ `SalesOrderItems_CommittedUnits`, `SalesOrderItems_Tags`, `SalesOrderItems`
1301
+ (`parentSalesOrderItemId` kit children), `SalesOrderItems_TransferOrderItems`,
1302
+ `TransferOrderItems_SalesOrderItems`. This is why the prune must clear the bridge links first and
1303
+ only ever touches never-fulfilled/never-invoiced lines.
1304
+ - **Proven (evidence):** NetSuite sales order 271929 (internal id 6926956), customer "6096 ODP
1305
+ Veyer (B2B)"; NetSuite reorganized it to 9 lines and removed 6 kitting lines (lineUniqueKeys
1306
+ 20024694/695/696/702/703/704) that never shipped and were never invoiced but each still held an
1307
+ upstream customer-PO link — those 6 blocked the renumber.
1308
+ - **⚠ OPEN, NOT FIXED — a DUPLICATE `SalesOrders` record on one NetSuite internal id.** `SalesOrders`
1309
+ number **467435086001** shares NetSuite internal id **6926956** with order 271929 (two TOGa
1310
+ records for one NetSuite sales order; 467435086001 has 12 keyless lines with downstream vendor-PO
1311
+ links, none shipped). The main SO lookup in `syncSalesOrderFromNetsuite` (~L946-974) filters
1312
+ Compass by `locationId 615286` / `customerId 3`, which currently narrows to 271929, so the prune
1313
+ only ever touches 271929 — but **two records on one NetSuite id is an anomaly that could make the
1314
+ lookup bind to the wrong record on other orders.** Needs its own ticket. Deploy: `library` only;
1315
+ the worker redeploys to pick it up (worker clones library on deploy).
1316
+
1273
1317
  - **⚠ Canon + Endeavor Health ITEM_RECEIPTS froze because `fetchPurchaseOrderById` never populated the
1274
1318
  PO vendor (deployed/verified 2026-08-29).** The PO shim's `->entity` was always null (its SuiteQL
1275
1319
  SELECT omitted `entity`, unlike `listPurchaseOrders`), so the fail-loud missing-entity guard in
@@ -1581,6 +1625,21 @@ library (or vice versa) crashes GroWrk and Adyen on their next sync run.
1581
1625
 
1582
1626
  ## Change history
1583
1627
 
1628
+ - 2026-09-15 — **Compass SALES_ORDERS unfrozen: a Compass-gated prune runs BEFORE the renumber.**
1629
+ `syncSalesOrderFromNetsuite` was throwing *"unresolvable NetSuite<->DB lineNumber conflict"*
1630
+ (`Logs.Issue` 749, clientId 2) because a NetSuite-removed line that still held an upstream
1631
+ customer-PO link survived the existing stale-line prune (which runs after the renumber and skips
1632
+ PO-linked lines) and occupied a `lineNumber` a surviving line needed. Added a prune BEFORE the
1633
+ renumber that, for each never-fulfilled/never-invoiced line whose non-empty `c_netsuiteLineUniqueKey`
1634
+ is absent from NetSuite, deletes its SO↔PO bridge links in both directions then the `SalesOrderItem`
1635
+ — never touching the `PurchaseOrder`/`PurchaseOrderItem` (Compass customer order). Guardrails:
1636
+ absent-from-NetSuite + non-empty key only, never shipped/invoiced, Compass-gated, immediate
1637
+ TOCTOU re-check, final DELETE stays fail-loud. Recorded that every FK child of
1638
+ `Client_Compass.SalesOrderItems` is `NO ACTION`/`RESTRICT` (all block a delete), and an OPEN
1639
+ duplicate-`SalesOrders`-record anomaly (467435086001 shares NetSuite internal id 6926956 with
1640
+ 271929). Proven on NetSuite SO 271929 (6 removed kitting lines blocked the renumber). `library`
1641
+ only, no SQL; worker redeploys to pick it up. cto AGREE; php-reviewer clean; cso SAFE TO SHIP.
1642
+ (jcardinal)
1584
1643
  - 2026-09-13 — **ITEM_FULFILLMENTS unfrozen for NYCHH: five stacked fulfillment-reconcile bugs fixed
1585
1644
  (library, deployed + verified in prod).** (1) **Unit duplication from a truncated nested GET** — the
1586
1645
  existing-units dedup map was built from the nested `itemFulfillmentItemUnits` of one
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-26
10
- owners: [apeterson, bala]
9
+ updated: 2026-09-15
10
+ owners: [apeterson, bala, jcardinal]
11
11
  files:
12
12
  - _underscore/Model/Client/PurchaseOrders/SalesOrder.php
13
13
  - _underscore/Model/Client/SalesOrders/PurchaseOrder.php
@@ -40,6 +40,18 @@ one returns `200` with an empty array, which reads like a permission or data bug
40
40
  Read the name as **"`<source>`\_`<thing created from it>`"**. The *first* table name is the
41
41
  origin; the second is what was generated from it.
42
42
 
43
+ **The line-item bridges follow the same rule (same name order, same directions):**
44
+
45
+ | Table | Direction | Meaning | API route |
46
+ |---|---|---|---|
47
+ | `PurchaseOrderItems_SalesOrderItems` | **UPSTREAM** | an SO line created **FROM** a customer PO line | `purchase-order-item-sales-order-items` |
48
+ | `SalesOrderItems_PurchaseOrderItems` | **DOWNSTREAM** | a vendor PO line created **FROM** an SO line | `sales-order-item-purchase-order-items` |
49
+
50
+ The 1.0 Compass NetSuite sync deletes these **item-level** link rows in **both** directions when it
51
+ prunes a NetSuite-removed SO line — see
52
+ [NetSuite → TOGa Supply per-client sync](../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md)
53
+ (2026-09-15).
54
+
43
55
  ## How it works
44
56
 
45
57
  - Both `Records` rows are `aclDatabase = CLIENT`, `childPolicy = MATCH_UPSERT`, so both are
@@ -122,6 +134,11 @@ everywhere. Present is not populated.
122
134
  collapses many POs without multiplying rows.
123
135
 
124
136
  ## Change history
137
+ - 2026-09-15 — Added the **line-item** bridge parallel: `PurchaseOrderItems_SalesOrderItems`
138
+ (upstream, route `purchase-order-item-sales-order-items`) and `SalesOrderItems_PurchaseOrderItems`
139
+ (downstream, route `sales-order-item-purchase-order-items`) follow the same name-order rule as the
140
+ header bridges. Confirmed while building the Compass 1.0 sync's pre-renumber prune, which deletes
141
+ both item-level link directions for a NetSuite-removed SO line. (jcardinal)
125
142
  - 2026-08-26 — Added the **write** side: the two directions are populated by two different
126
143
  pipelines (2.0 `_Trait_Netsuite_SalesOrder` writes **upstream** customer POs from `otherRefNum`;
127
144
  the 1.0 `syncPurchaseOrderFromNetsuite` cron writes **downstream** vendor POs), and the api2 child
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-08
9
+ updated: 2026-09-15
10
10
  owners: ["bala", "mhammontree", "tcox", "apeterson", "rgirish"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -234,6 +234,16 @@ production `Core.RecordFields` for `Items`:
234
234
  |---|---|---|---|
235
235
  | **107** | `Items.manufacturerId` | `MATCH_UPSERT` | **creates** the manufacturer — get-or-create by name works |
236
236
  | **269** | `Items.assetTypeId` | `MATCH` | **fails the entire item write** |
237
+ | **914** | `Items.catalogId` | `MATCH` | **matches** the existing catalog — and can never create one |
238
+
239
+ Row 914 is the benign shape of `MATCH`, and it is worth knowing why it is benign: the match set is
240
+ built from **`isIdentifier`**, and the `catalogs` record carries `isIdentifier = 1` on **`id`,
241
+ `uuid` AND `name`** (verified on production `Core.RecordFields`, 2026-09-15). So
242
+ `catalog: {name: 'Compass'}` resolves to the existing `Catalogs` row by name and **cannot mint a
243
+ new catalog** — which is what makes a name-keyed catalog safe to seed as a per-tenant default
244
+ (see [surface-layer-schema](../../dbchanges2/features/surface-layer-schema.md)). The identifier set
245
+ is per-record data, not a code constant: check `isIdentifier` before assuming a business key is
246
+ matchable.
237
247
 
238
248
  The 1.0 NetSuite importer's `getCreateItem()` contains **both** calls. `getCreateManufacturer()`
239
249
  sends `manufacturer: {name}` and has always worked; copying that shape for `assetType: {name}`
@@ -442,6 +452,11 @@ only one tenant's copy has the wrong flag.
442
452
 
443
453
  ## Change history
444
454
 
455
+ - 2026-09-15 — Added **`Items.catalogId` (recordFieldId 914, `MATCH`)** to the childPolicy table as
456
+ the *benign* `MATCH` case, and recorded why: the `catalogs` record has `isIdentifier = 1` on
457
+ **`id`, `uuid` and `name`** (prod `Core.RecordFields`), so `catalog: {name: '<catalog>'}` always
458
+ links the existing row and can never create a catalog. That is what lets a tenant ship a
459
+ name-keyed catalog as an item-create default. (bala)
445
460
  - 2026-09-08 — ⚠ Recorded the **third EV-12 flavor: the field is declared in PHP but
446
461
  `isIdentifier = 0` in the tenant's `CustomRecordFields`.** `V2.php` (~L7471-7530) builds
447
462
  `$recordIdentifiers` purely from DB metadata (`Core.RecordFields` + `Client_<X>.CustomRecordFields`
@@ -6,9 +6,12 @@ project: Database Changes
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-04
10
- owners: [jcardinal, apeterson, tcox]
9
+ updated: 2026-09-15
10
+ owners: [jcardinal, apeterson, tcox, bala]
11
11
  files:
12
+ - dbchanges2/Core/2026-09-15a - ItemCreateDefaultsSurfaceSeed.sql
13
+ - dbchanges2/Client_Compass/2026-09-15a - ItemCreateCatalogDefault.sql
14
+ - dbchanges2/Client_CompassCanada/2026-09-15a - ItemCreateCatalogDefault.sql
12
15
  - dbchanges2/Client_Compass/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql
13
16
  - dbchanges2/Client_CompassCanada/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql
14
17
  - dbchanges2/Core/2026-09-02a - TalosAssistantSurfaceSeed.sql
@@ -1372,7 +1375,100 @@ environment and take the **highest** `MAX(id)+1`, never the one in front of you,
1372
1375
  `Core.SurfaceElements.id` = **233**, next `Core.Messages.id` = **299**. Both consuming files were
1373
1376
  still unrun at the time, so this is a reservation, not a deployed state.
1374
1377
 
1378
+ ## A marker element can carry per-tenant WRITE defaults, not just visibility (`item-record-create-defaults`, Core 61, 2026-09-15)
1379
+
1380
+ The marker-element pattern (a hidden element whose `config` is the payload) had so far only carried
1381
+ **visibility / mode** flags. `Core/2026-09-15a - ItemCreateDefaultsSurfaceSeed.sql` uses it to carry
1382
+ **default field VALUES for a create form**: surface **`item-record-create-defaults`** (type
1383
+ `SECTION`, `config.cardType = 'createDefaults'`) with one element, `isVisible = 0`, whose config is
1384
+
1385
+ ```json
1386
+ {"role":"createDefaults","values":{}}
1387
+ ```
1388
+
1389
+ Core seeds `values` **empty** — the true-neutral default — and each tenant opts in with a single
1390
+ `SurfaceOverrides` **`CONFIG`** row. The consuming app spreads `values` into its `POST` body before
1391
+ the form fields, so a default never beats real user input. FE side:
1392
+ [surface-frontend](../../toga25-supply/features/surface-frontend.md). This shipped to fix items
1393
+ created in toga25-supply being written with `Items.catalogId = NULL` (28 rows in prod
1394
+ `Client_Compass`, 6 in `Client_CompassCanada`).
1395
+
1396
+ **Why `name` and not `uuid` in the seeded value.** `Items.catalogId` is `Core.RecordFields` **914**
1397
+ with `childPolicy = MATCH`, and the `catalogs` record has `isIdentifier = 1` on `id`, `uuid` **and**
1398
+ `name` — so a name matches the existing row and **can never create a catalog**
1399
+ ([nested-relationship writes](../../api2/features/nested-relationship-writes.md)). A name also keeps
1400
+ the seed readable and tenant-portable, where a uuid would not be.
1401
+
1402
+ **The two tenants spell the catalog differently, and the seeds use the STORED spelling:**
1403
+
1404
+ | Tenant | Seeded value | `Catalogs` reality |
1405
+ |---|---|---|
1406
+ | `Client_Compass` | `{"catalog":{"name":"Compass"}}` | id 1 **`Compass`** (also id 2 `Agilant`, id 3 `Office Depot`) |
1407
+ | `Client_CompassCanada` | `{"catalog":{"name":"compass"}}` | id 1 **`compass`** — the only row |
1408
+
1409
+ Both `name` columns are case-insensitive (`utf8mb4_unicode_ci` on Compass,
1410
+ `utf8mb4_0900_ai_ci` on Canada), so either spelling would in fact match; matching the stored
1411
+ spelling is a readability rule, not a correctness one. Catalog background:
1412
+ [Compass item catalogs](../../../clients/compass-usa/features/item-catalogs-and-duplicate-items.md).
1413
+
1414
+ ### Both client files restate `role` — the CONFIG-replaces-wholesale rule, hit again
1415
+
1416
+ A `CONFIG` override **replaces the element's config object entirely** (`Surface.php:839`, see
1417
+ [the load-bearing rule](#-a-config-override-replaces-config-wholesale--this-is-the-load-bearing-rule-of-the-whole-design)),
1418
+ so each client row must ship **`role` AND `values`**:
1419
+
1420
+ ```json
1421
+ {"role":"createDefaults","values":{"catalog":{"name":"Compass"}}}
1422
+ ```
1423
+
1424
+ Send only `values` and the element loses its `role`, the adapter stops finding the marker, and the
1425
+ default silently disappears with no error anywhere. This is now the **second** feature to hit it
1426
+ (after the Talos suggestion chips) — treat "restate every key the FE matches on" as the default
1427
+ authoring step for any CONFIG override, not a special case.
1428
+
1429
+ ### The client files hardcode the Core ids — and the 2026-07-20d precedent is NOT copyable
1430
+
1431
+ `Client_Compass/2026-09-15a` and `Client_CompassCanada/2026-09-15a` inline the Core `surfaceId` /
1432
+ `surfaceElementId` read from Core **after** the Core file was run, and guard re-runs with
1433
+ `SELECT … FROM DUAL WHERE NOT EXISTS (…)` against the **client table only** — no `Core.*` read at
1434
+ all.
1435
+
1436
+ **Confirmed again by a real failure this session:** querying `Client_Compass` with a join to `Core`
1437
+ returns `OperationalError (1049, "Unknown database 'Core'")` on the `prod-client` cluster. The
1438
+ nearest existing example, `Client_Compass/2026-07-20d - ItemRecordVendorItemsEnable.sql`, resolves
1439
+ its element id with `FROM Core.SurfaceElements JOIN Core.Surfaces` — **do not copy it.** It is one of
1440
+ the ~51 stale cross-cluster files described above; it landed in prod by hand transposition, not by
1441
+ running as written.
1442
+
1443
+ ### Ids consumed — and beta must be RE-READ, not reused
1444
+
1445
+ `Surfaces`/`SurfaceElements` ids differ sharply per environment: before this work prod Core was at
1446
+ `Surfaces` **60** / `SurfaceElements` **233**, while dev-sandbox (beta) was at **23** / **65**. The
1447
+ developer ran the Core file **on PROD**, taking **surface 61** and **element 234**, and the two
1448
+ client files hardcode those prod ids.
1449
+
1450
+ > ⚠ **Beta has not had the Core file run.** Running the client files on beta as written would point
1451
+ > the overrides at ids that mean something else (or nothing) there. Re-read the beta ids after
1452
+ > running the Core file and swap them in before running the client files.
1453
+
1454
+ **Handoff published (verify before your own run):** next prod `Core.Surfaces.id` = **62**, next prod
1455
+ `Core.SurfaceElements.id` = **235**.
1456
+
1375
1457
  ## Change history
1458
+ - 2026-09-15 — Added the **`item-record-create-defaults`** seed trio (`Core/2026-09-15a` +
1459
+ `Client_Compass` / `Client_CompassCanada` `2026-09-15a - ItemCreateCatalogDefault.sql`), which
1460
+ extends the marker-element pattern from visibility flags to **per-tenant create-form default
1461
+ VALUES** (`config {"role":"createDefaults","values":{}}`, Core seeds `values` empty, each tenant
1462
+ overrides). Shipped to stop toga25-supply writing `Items.catalogId = NULL`. Recorded: the value is
1463
+ seeded by catalog **name** because `RecordFields` 914 is `MATCH` over a record with
1464
+ `isIdentifier` on `name` (so it can never mint a catalog); the two tenants store the name with
1465
+ different case (`Compass` vs `compass`, both CI collations); both client files must restate
1466
+ **`role`** because a CONFIG override replaces the config wholesale (second feature to hit that);
1467
+ and the client files hardcode Core ids with a client-only `NOT EXISTS` guard — re-confirmed by a
1468
+ live `1049 Unknown database 'Core'` on `prod-client`, which also makes the older
1469
+ `2026-07-20d - ItemRecordVendorItemsEnable.sql` a NON-copyable precedent. Prod consumed
1470
+ **Surface 61 / SurfaceElement 234** (beta is at 23 / 65 and has NOT run the Core file — re-read
1471
+ its ids first); next prod ids = **62** / **235**. (bala)
1376
1472
  - 2026-09-04 — Added the **restrict-to-persona opt-in pair** (`Client_Compass/2026-09-04a` +
1377
1473
  `Client_CompassCanada/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql`): client-wide
1378
1474
  `IS_VISIBLE`+`IS_ENABLED` overrides on `surfaceId` **19** / `surfaceElementId` **57**, literal Core
@@ -6,8 +6,8 @@ project: TOGa 2.5 Supply
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-04
10
- owners: [jcardinal, apeterson, tcox, rgirish]
9
+ updated: 2026-09-15
10
+ owners: [jcardinal, apeterson, tcox, rgirish, bala]
11
11
  files:
12
12
  - toga25-supply/src/layout/RecordApprovalModal/helpers/stackedCurrencyJoiner.ts
13
13
  - toga25-supply/src/layout/RecordApprovalModal/helpers/stackedCurrencyJoiner.test.ts
@@ -71,6 +71,7 @@ files:
71
71
  - toga25-supply/src/pages/Inventory/viewModel/FIELDS/index.ts
72
72
  - toga25-supply/src/fieldsConfig/index.ts
73
73
  - toga25-supply/src/layout/ItemRecordModalLayout/helpers/surfaceBundleToItemFields.ts
74
+ - toga25-supply/src/layout/ItemRecordModalLayout/hooks/useItemCreateForm.tsx
74
75
  - toga25-supply/src/layout/ItemRecordModalLayout/helpers/index.ts
75
76
  - toga25-supply/src/layout/ItemRecordModalLayout/viewModel/useItemRecordModalViewModel.tsx
76
77
  - toga25-supply/src/layout/ItemRecordModalLayout/ItemRecordModalLayout.tsx
@@ -402,6 +403,64 @@ a `computeValueKey`, grep the consuming view's registry for that literal string
402
403
  > makes the field visible and therefore **crashes the modal**; the FE alone changes nothing visible.
403
404
  > Same shape as the approvals-gate ship-together dependency.
404
405
 
406
+ ## Item CREATE defaults also come from Surface — and a missing default wrote NULL `catalogId` (2026-09-15)
407
+
408
+ The item modal's **create** payload now takes its per-tenant defaults from the Surface layer instead
409
+ of a client branch in code. This started as a data bug: `useItemCreateForm` built the `POST /items`
410
+ body with **no catalog at all**, so every item created in this app was saved with
411
+ `Items.catalogId = NULL`. The old `toga2-supply` `CreateItemModal.tsx` had always sent
412
+ `catalog: { name: "Compass" }` — which is why only the 2.5 app produced NULL rows, and why nobody
413
+ saw it in the older app. **Measured on prod 2026-09-15:** `Client_Compass.Items` held **28** rows
414
+ with a NULL `catalogId` (newest 2026-09-14) against 1,509 on catalog 1, and
415
+ `Client_CompassCanada.Items` **6** NULL against 221. A NULL-catalog item **never reaches the
416
+ storefront** — see
417
+ [Compass item catalogs](../../../clients/compass-usa/features/item-catalogs-and-duplicate-items.md).
418
+
419
+ **How it works now** — the same marker-element shape as `item-record-vendor-items`, read by a new
420
+ adapter:
421
+
422
+ - New Core surface **`item-record-create-defaults`** (type `SECTION`, `surface.config.cardType =
423
+ 'createDefaults'`) carrying **one non-rendered marker element** with
424
+ `config = {"role":"createDefaults","values":{}}` and `isVisible = 0`.
425
+ - The slug was added to **`ITEM_RECORD_SURFACE_SLUGS`**, so it rides the group fetch the record
426
+ modal already makes — no new request.
427
+ - **`surfaceBundleToItemCreateDefaults()`** (`helpers/surfaceBundleToItemFields.ts`, exported via
428
+ `helpers/index.ts`) finds the marker by `config.role === 'createDefaults'` and returns its
429
+ `config.values` object.
430
+ - `useItemCreateForm` **spreads the defaults FIRST** into the `POST /items` body, so a real form
431
+ field always wins over a default. Order is the whole contract here.
432
+ - A tenant opts in with one `SurfaceOverrides` CONFIG row. Compass seeds
433
+ `{"catalog":{"name":"Compass"}}`; Compass Canada seeds `{"catalog":{"name":"compass"}}` — the
434
+ spelling differs per tenant, see
435
+ [surface-layer-schema](../../dbchanges2/features/surface-layer-schema.md).
436
+
437
+ **Why this is the right seam:** a tenant default is presentation/tenant config, so it belongs in the
438
+ surface layer, not in a hostname or client-slug switch in the create form. Adding the next default
439
+ (vendor, inventory type, anything) is now a `values` key plus a seed row — zero FE change.
440
+
441
+ ### Adding a slug to a group fetch is SAFE on unseeded clients and environments
442
+
443
+ `_buildBundle` returns `{ surface: null, elements: [] }` when `$surface->load()` fails, so a slug
444
+ that has not been seeded in a tenant (or a whole environment where the Core file has not run) simply
445
+ resolves to an empty bundle. The adapter then returns `{}` and the create payload is byte-identical
446
+ to before. Verified against `Model/Core/Surface.php` — see
447
+ [surface-resolver](../../_underscore/features/surface-resolver.md). **So the FE can ship before the
448
+ SQL runs anywhere** — the reverse of the restrict-to-persona deploy-order rule above, because this
449
+ element renders nothing.
450
+
451
+ ### Sending `catalog` by NAME does not trip the Compass POST interceptor
452
+
453
+ `_Model_Compass_Item::postPost` throws *"Could not load catalog uuid"* when it receives a catalog
454
+ with no uuid, which looks like a reason to send a uuid instead of a name. It is not. api2 invokes the
455
+ POST payload interceptor with **`$outData` — the created record, not the submitted body**
456
+ (`V2.php:6019`), and the created record always carries the resolved catalog uuid. Compass has an
457
+ active POST/POST interceptor on `recordId 21` (Items); Compass Canada has one too, but there is no
458
+ `_Model_CompassCanada_Item`, so it falls back to `_Model_Client_Item::postPost`, which returns early
459
+ unless `isFulfillable` was sent. Background:
460
+ [API payload interceptors](../../api2/features/api-payload-interceptors.md). The name→row match
461
+ itself is safe because `Items.catalogId` is a `MATCH` field over a record whose `name` is an
462
+ identifier — [nested-relationship writes](../../api2/features/nested-relationship-writes.md).
463
+
405
464
  ## SalesOrders approval-decision modal (approve + deny) — migrated via an OVERLAY adapter seam
406
465
 
407
466
  The SalesOrder approve/deny decision modal's config now sources its display fields from Surface,
@@ -1100,6 +1159,17 @@ claim is about the `navigation-*` **flags** being inert — still true — not a
1100
1159
  unused).
1101
1160
 
1102
1161
  ## Change history
1162
+ - 2026-09-15 — **Item create defaults moved to the Surface layer, fixing NULL `catalogId` on every
1163
+ item created in this app.** `useItemCreateForm` sent no catalog at all (prod: 28 NULL-catalog rows
1164
+ in `Client_Compass`, 6 in `Client_CompassCanada`; the older `toga2-supply` modal had always sent
1165
+ `catalog: {name:"Compass"}`), and a NULL-catalog item never reaches the storefront. New Core
1166
+ surface `item-record-create-defaults` carries a hidden `config.role='createDefaults'` marker whose
1167
+ `config.values` each tenant overrides; the slug rides the existing `ITEM_RECORD_SURFACE_SLUGS`
1168
+ group fetch, `surfaceBundleToItemCreateDefaults()` adapts it, and the create form spreads it
1169
+ **first** so form fields still win. Recorded two rules that make this cheap: an unseeded slug
1170
+ resolves to an empty bundle (so FE can ship before the SQL, unlike the restrict-to-persona case),
1171
+ and sending `catalog` by **name** cannot trip `_Model_Compass_Item::postPost` because api2 hands a
1172
+ POST interceptor the created record, not the request body. (bala)
1103
1173
  - 2026-09-04 — Fixed the item modal's **More Info → "Restrict to Persona"** field missing in VIEW for
1104
1174
  Compass USA + Canada. Two causes, both now rules here: (1) the item modal's VIEW is Surface-driven
1105
1175
  while EDIT is still JSON, so a Core-default-OFF element with no client `SurfaceOverride` vanishes
@@ -17,7 +17,7 @@ project: _Underscore
17
17
  client: compass-canada
18
18
  type: profile
19
19
  status: active
20
- updated: 2026-09-10
20
+ updated: 2026-09-15
21
21
  owners: [jcardinal, bala, tcox, apeterson, ajean]
22
22
  files: []
23
23
  related:
@@ -61,6 +61,13 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
61
61
  resolution + client constants that all 8 Canada crons depend on. `library` is in `apps` for that
62
62
  reason.
63
63
 
64
+ - **Catalogs: one row, lowercase.** `Client_CompassCanada.Catalogs` holds a **single** row, id 1
65
+ name **`compass`** (lowercase) — not the `Compass` / `Agilant` / `Office Depot` trio Compass USA
66
+ has ([item catalogs](../compass-usa/features/item-catalogs-and-duplicate-items.md)). The column
67
+ collation is case-insensitive, but write the stored spelling when seeding a value that references
68
+ it by name. Items created in `toga25-supply` were saved with a NULL `catalogId` until 2026-09-15
69
+ (6 rows here); the fix seeds the catalog as a Surface create-default per tenant.
70
+
64
71
  ## Vendors & integrations
65
72
  - **Grand & Toy (G&T)** — primary hardware vendor. SOs flow toga → MITS → PO to G&T; G&T sends
66
73
  back ASNs. ASN ingestion (email CSV + the auto-created ItemFulfillment chain + bilingual
@@ -6,7 +6,7 @@ project: Library
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-02
9
+ updated: 2026-09-15
10
10
  owners: ["bala", "jcardinal"]
11
11
  files:
12
12
  - library/app/api/toga2.php
@@ -214,6 +214,15 @@ children" rule:
214
214
  - **⚠ A NULL `catalogId` is a real state** (27 items in prod, 22 ODP `VendorItems` point at such
215
215
  items). Anything joining through `Catalogs` **drops those rows silently** — an inner join to
216
216
  `Catalogs` is itself a filter.
217
+ **Where the newest NULL rows came from is now known, and it is fixed (2026-09-15):** the
218
+ `toga25-supply` item **create** form posted no catalog at all, so every item created in the 2.5 app
219
+ was born with `catalogId = NULL` — **28** such rows in `Client_Compass` (newest 2026-09-14) and
220
+ **6** in `Client_CompassCanada`, against 1,509 / 221 on catalog 1. A NULL-catalog item never
221
+ reaches the storefront. The create form now takes `catalog: {name: 'Compass'}` from a seeded
222
+ Surface default instead of hardcoding it (the older `toga2-supply` modal always sent it, which is
223
+ why the NULL rows are all from the new app) — see
224
+ [surface-frontend](../../../2.0/apps/toga25-supply/features/surface-frontend.md). The existing NULL
225
+ rows still need a data repair; the leak is closed, the backlog is not.
217
226
  - **⚠ `BundleItems.itemId` is a silent catalog-resolution consumer — the storefront never
218
227
  re-catalogs it.** The `toga2-commerce` storefront relays whatever item `GET /v2/bundles/{uuid}`
219
228
  returns; its `catalogId = 1` filter guards standalone item search only, never bundles. So a kit
@@ -241,6 +250,11 @@ children" rule:
241
250
  [catalog-fold reversal](../workflows/catalog-fold-reversal.md).
242
251
 
243
252
  ## Change history
253
+ - 2026-09-15 — Identified the **source of the newest NULL-`catalogId` items**: the `toga25-supply`
254
+ create form sent no catalog, so every item created in the 2.5 app was born catalog-less (**28** rows
255
+ in `Client_Compass`, newest 2026-09-14; **6** in `Client_CompassCanada`), and such an item never
256
+ reaches the storefront. The leak is closed — the create payload now takes its catalog from a seeded
257
+ Surface default — but the existing NULL rows are still unrepaired. (bala)
244
258
  - 2026-09-02 — **Narrowed the same-day uniqueness resolution below.** `Client_Compass.Items` holds
245
259
  `MD7F4LL/A-S` **twice** — id 2760 (`title` set, `isActive = 0`, the row all referencing bundles
246
260
  use) and id 2784 (`title` NULL, `isActive = 1`, unused) — a pair from outside the fold population, so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.809",
3
+ "version": "1.0.811",
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",