toga-ai 1.0.779 → 1.0.781

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.
@@ -4,7 +4,7 @@
4
4
  |-----|---------|-------|
5
5
  | [Proposed — git-sourced base+overlay JSON authoring for the Surface layer](architecture/surface-authoring-proposal.md) | A **proposal / handoff recommendation** (not implemented) that the Surface layer's *authoring* model move off hand-authored SQL against the `SurfaceOverrides` E | _underscore/Model/Core/Surface.php, _underscore/Model/Client/SurfaceOverride.php |
6
6
  | [_underscore Framework Architecture](architecture.md) | `_underscore` is the shared PHP backend framework for **all 2.0 applications**. | _underscore/_underscore.php, _underscore/Loader.php, _underscore/Framework.php, _underscore/Model.php, _underscore/Database.php, _underscore/Query.php, _underscore/Route.php, _underscore/Component.php |
7
- | [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, dbchanges2/Core/2026-08-27a - TransferOrderUuidSearchableIdentifier.sql, _underscore/Model/Core/Page.php, _underscore/Model/Core/Surface.php, _underscore/Model/Client/TrackingNumber.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql, dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql, dbchanges2/Client/2026-08-29 - ItemReceiptTransferOrderIdFieldPermission.sql, dbchanges2/Client/2026-08-29b - ItemReceiptItemTransferOrderItemIdFieldPermission.sql, dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql |
7
+ | [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, dbchanges2/Core/2026-08-27a - TransferOrderUuidSearchableIdentifier.sql, _underscore/Model/Core/Page.php, _underscore/Model/Core/Surface.php, _underscore/Model/Client/TrackingNumber.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql, dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql, dbchanges2/Client/2026-08-29 - ItemReceiptTransferOrderIdFieldPermission.sql, dbchanges2/Client/2026-08-29b - ItemReceiptItemTransferOrderItemIdFieldPermission.sql, dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql, dbchanges2/Client_Nychh/2026-09-03a - TransferOrdersStatusBadge.sql |
8
8
  | [Address Uniqueness Normalization (unit identifier + 5-digit ZIP comparison)](features/address-uniqueness-normalization.md) | When a business rule says *"only one X per physical address"*, comparing address rows field-for-field does **not** work: the same dwelling is spelled many diffe | _underscore/Model/Rate/Entitlement.php |
9
9
  | [Address Validation (carrier waterfall + validateAddress scripted endpoint)](features/address-validation.md) | `_Model_Client_Address::validateAddress` verifies a US address against a **carrier waterfall (USPS → FedEx → UPS)** and returns a single canonical, carrier-norm | _underscore/Model/Client/Address.php, _underscore/Component/Library/Carriers/Usps/Usps.php |
10
10
  | [_ApiRequest — JSON encode/decode & api-logging behavior](features/apirequest-json-content-type.md) | `_ApiRequest` is the 2.0 outbound HTTP client. | _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php, _underscore/Component/Api/Clickup/Clickup.php |
@@ -22,6 +22,7 @@ files:
22
22
  - dbchanges2/Client/2026-08-29 - ItemReceiptTransferOrderIdFieldPermission.sql
23
23
  - dbchanges2/Client/2026-08-29b - ItemReceiptItemTransferOrderItemIdFieldPermission.sql
24
24
  - dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql
25
+ - dbchanges2/Client_Nychh/2026-09-03a - TransferOrdersStatusBadge.sql
25
26
  ---
26
27
 
27
28
  ## Summary
@@ -97,6 +98,17 @@ Two distinct failure shapes, and the second is a page-killer:
97
98
  `undefined`. Code that checks `=== null` will not detect it.
98
99
  - **A requested JOINED field with no grant fails the ENTIRE request with `EZ-2`.** One missing grant
99
100
  on one joined column therefore blanks every record the page asked for — not just that column.
101
+ - **⚠ An ungranted `uuid` fails the ENTIRE request too — even though you never asked for it.**
102
+ `uuid` is V2's `IDENTIFIER_FIELD` (`api2 Component/Api/V2/V2.php:13`) and is **force-added to every
103
+ record read**, so it is always "requested" whether or not it appears in your `fields=` list. If the
104
+ calling role has no `AclFieldPermissions` grant on that record's `uuid` RecordField, the response is
105
+ **403 `EZ-2`** naming `"fields": ["uuid"]`. This is the opposite of the first bullet: an ordinary
106
+ ungranted field is silently dropped, `uuid` is fatal.
107
+
108
+ **Rule: grant the `uuid` RecordField alongside every field you actually read.** Seen 2026-09-03 on
109
+ `/transfer-order-statuses` — the fetch asked only for `slug,name,colorHex` and still 403'd on
110
+ `uuid`. The error text is self-diagnosing here: an `EZ-2` whose `fields` list contains a field you
111
+ never sent is this.
100
112
 
101
113
  **Two practices follow directly:**
102
114
 
@@ -652,6 +664,14 @@ hardcoded `Core.RecordFields` id literals instead of a subselect.
652
664
  and every repo is on the **same branch** so the generated model matches the DB.
653
665
 
654
666
  ## Change history
667
+ - 2026-09-04 — ⚠ Recorded that an **ungranted `uuid` 403s the whole request**, unlike an ordinary
668
+ ungranted field which is silently dropped: `uuid` is V2's `IDENTIFIER_FIELD`
669
+ (`api2 Component/Api/V2/V2.php:13`) and is force-added to **every** record read, so a role with no
670
+ `AclFieldPermissions` row on that record's `uuid` RecordField gets `EZ-2` listing
671
+ `"fields": ["uuid"]` even when `uuid` was never requested. Hit on `/transfer-order-statuses` while
672
+ wiring the transfer-order status badge (the fetch asked only for `slug,name,colorHex`). Rule: grant
673
+ the `uuid` RecordField alongside the fields you read; and an `EZ-2` naming a field you never sent
674
+ is this. (apeterson)
655
675
  - 2026-09-04 — Recorded the **custom-field silent-omission trap** after a production incident (Rate
656
676
  AIG): a `c_` field with no `AclCustomFieldPermissions` row for the calling role is **dropped from a
657
677
  GET response with HTTP 200** and a correct `totalRecordCount` when `fields=` is not sent — the
@@ -12,7 +12,7 @@
12
12
  | [Language Translation Layer (audience.language + sidecar tables)](features/language-translation-layer.md) | Serves the same TOGa data (Item title/description/longDescription, plus item **feature** text — `Features.name`, `ItemCategoryFeatureGroups.name`, `ItemFeatures | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, _underscore/Model/Core/Setting.php, _underscore/Model/Core/RecordField.php, _underscore/Model/Core/DefaultGlobalSetting.php, _underscore/Model/Client/ItemTranslation.php, _underscore/Model/Client/FeatureTranslation.php, _underscore/Model/Client/ItemCategoryFeatureGroupTranslation.php, _underscore/Model/Client/ItemFeatureTranslation.php, dbchanges2/Client/2026-06-23a - ItemTranslations.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-13a - FeatureTranslations.sql, dbchanges2/Client/2026-07-13b - FeatureTranslationsAcl.sql, dbchanges2/Core/2026-06-23a - RecordFieldsTranslationColumn.sql, dbchanges2/Core/2026-06-23b - ItemTranslationsRecord.sql, dbchanges2/Core/2026-07-13 - FeatureTranslationsRecord.sql |
13
13
  | [/auth/login resolves the client from the email domain, not the Bearer token (cross-client user path)](features/login-cross-client-user-resolution.md) | `POST /v2/auth/login` (email/password user login) can silently swap the target client mid-request. | api2/Component/Api/V2/V2.php, _underscore/String.php |
14
14
  | [Nested FK object embedding is gated by the CHILD record's own ACL](features/nested-fk-acl-embedding.md) | When the V2 JSON engine serializes a foreign-key field into a **nested object** (in `getFullModelData()`, ~V2.php L6016-6060), it re-checks the **child** record | api2/Component/Api/V2/V2.php, dbchanges2/Client_Compass/2026-07-23b - PurchaseOrdersRecordReadAcl.sql |
15
- | [Nested-relationship writes & child matching (link vs. create)](features/nested-relationship-writes.md) | When a 2.0 API write payload (`POST`/`PUT`) contains a **nested related object** (e.g. | api2/Component/Api/V2/V2.php, _underscore/Model/Client/ContactEmailAddress.php |
15
+ | [Nested-relationship writes & child matching (link vs. create)](features/nested-relationship-writes.md) | When a 2.0 API write payload (`POST`/`PUT`) contains a **nested related object** (e.g. | api2/Component/Api/V2/V2.php, _underscore/Model/Client/ContactEmailAddress.php, _underscore/Model/Client/TransferOrderStage.php |
16
16
  | [Record Scripts (computed/aggregate /v2 endpoints — the authoring contract)](features/record-scripts.md) | In api2 you almost never write a controller. | api2/Component/Api/V2/V2.php, _underscore/Model/Team/Sprint.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Query.php |
17
17
  | [V2 Request Logging & Where Requests Land (client vs core log DB)](features/request-logging.md) | The V2 engine logs **every inbound request** — success *and* failure, with response code and payload — and routes each log entry to the **client** log or the ** | api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Client/Logs/Api.php, _underscore/Model/Core/Logs/Api.php |
18
18
  | [POST + JSON-body args for scripted APIs](features/scripted-api-post-body-args.md) | The V2 engine can run a Record Script (scripted API) for a **POST** request, and a scripted API can receive its arguments from the **JSON request body** instead | api2/Component/Api/V2/V2.php |
@@ -6,11 +6,12 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-26
10
- owners: ["bala", "mhammontree", "tcox"]
9
+ updated: 2026-09-04
10
+ owners: ["bala", "mhammontree", "tcox", "apeterson"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - _underscore/Model/Client/ContactEmailAddress.php
14
+ - _underscore/Model/Client/TransferOrderStage.php
14
15
  related:
15
16
  - ../architecture.md
16
17
  - ../../toga2-supply/features/currency-amount-lines-editor.md
@@ -311,6 +312,33 @@ completely invisible to the UI — see
311
312
  Same family as the 201-with-dropped-data case above: **a success status from V2 proves only that the
312
313
  parent was written.** Assert child row counts, or write children explicitly.
313
314
 
315
+ ## ⚠ Not every record can be linked by `uuid` — read `searchableIdentifierFields`
316
+
317
+ `{ uuid }` is the house convention for a nested FK, and it works so consistently that it reads like a
318
+ platform guarantee. **It is not.** A record is linkable only by the fields flagged as identifiers,
319
+ and a handful of lookup tables have **no `uuid` identifier at all**. Sending `{ uuid }` to one of
320
+ those is a **400 `EV-12`**, not a silent miss.
321
+
322
+ ```
323
+ // WRONG — TransferOrderStages has no uuid identifier
324
+ transferOrderStage: { uuid: "…" }
325
+ // → 400 EV-12, "searchableIdentifierFields": ["id","c_netsuiteInternalTransferOrderStatus"]
326
+
327
+ // CORRECT
328
+ transferOrderStage: { id: 7 }
329
+ ```
330
+
331
+ - **The error body is self-diagnosing — read `searchableIdentifierFields` first.** It lists exactly
332
+ what that record accepts. No DB query needed.
333
+ - **When you fall back to `id`, resolve it at request time by a stable natural key (name/slug), never
334
+ hardcode it.** `id` values differ per tenant database, so a literal that works in one client DB
335
+ points at a different row (or nothing) in another. The transfer-order create modal looks its
336
+ opening stage up by name `"Pending"` on each request for exactly this reason.
337
+ - **The other EV-12 flavor is different.** `searchableIdentifierFields: ["id"]` on a nested FK
338
+ resolved *through* a custom field means a `c_` field is missing `isIdentifier = 1` — that is a
339
+ metadata bug to fix (see above), whereas "this record genuinely has no `uuid`" is a payload shape
340
+ to accept.
341
+
314
342
  ## Gotcha
315
343
 
316
344
  - **Nested write with only a non-identifier field silently creates duplicates.** This is a
@@ -322,6 +350,14 @@ parent was written.** Assert child row counts, or write children explicitly.
322
350
 
323
351
  ## Change history
324
352
 
353
+ - 2026-09-04 — ⚠ Recorded that **`{ uuid }` is a convention, not a guarantee**: some lookup records
354
+ (e.g. `TransferOrderStages`) have **no `uuid` identifier**, so a nested `{ uuid }` is a hard 400
355
+ `EV-12` naming `searchableIdentifierFields: ["id","c_netsuiteInternalTransferOrderStatus"]`. Read
356
+ that list first — it is self-diagnosing — and when falling back to `{ id }`, resolve the id at
357
+ request time from a stable natural key (name/slug), because ids differ per tenant DB. Distinguished
358
+ this from the existing `searchableIdentifierFields: ["id"]` flavor, which is a missing
359
+ `isIdentifier` on a `c_` field. Found building the transfer-order create POST. (apeterson)
360
+
325
361
  - 2026-08-26 — Added: **the UPDATE path sets status 200 unconditionally after its children loop**, so
326
362
  a nested `PUT` reports success even when a child write failed (unique-key rejection, ACL denial).
327
363
  Any parent-with-children editor that needs to know whether the children landed must write them
@@ -4,7 +4,7 @@
4
4
  |-----|---------|-------|
5
5
  | [Database Changes (dbchanges2) Repository Architecture](architecture.md) | `dbchanges2` is the **schema-migration / SQL change-set repository** for the entire 2.0 platform. | Core/, Client/, Client_<Tenant>/, Logs/, Logs_Client/, _modules/ |
6
6
  | [Re-runnable additive migrations (uuid4 in SQL, INSERT guards, the DISTINCT trap, conditional ALTERs)](features/rerunnable-additive-inserts.md) | Most `dbchanges2` files are **additive data grants** run by hand against production, often more than once (once per environment, or twice because someone was no | dbchanges2/Client_Compass/2026-08-26a - CompassCreativeStudioPersona.sql, dbchanges2/Core/2026-08-27a - Insert - Netsuite Location SyncAll CronJob.sql, dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql |
7
- | [Surface Layer Schema (UI presentation/config tables)](features/surface-layer-schema.md) | The persistent schema for the platform-wide **Surface** UI presentation/configuration layer (see the `_underscore` [surface-resolver](../../_underscore/features | dbchanges2/Core/2026-09-02a - TalosAssistantSurfaceSeed.sql, dbchanges2/Client_Nychh/2026-09-02a - TalosAssistantEnable.sql, dbchanges2/Core/2026-08-31a - SalesOrderDenialDetailsSurfaceSeed.sql, dbchanges2/Client_Compass/2026-08-31a - SalesOrderDenialDetailsReadOnlyRules.sql, toga25-supply/db-migrations/PLAYBOOK.md, toga25-supply/db-migrations/SURFACE-FEATURE-RUNBOOK.md, dbchanges2/Client_Compass/, dbchanges2/Client_CompassCanada/, dbchanges2/Client_Quad/, dbchanges2/Core/2026-08-28b - TransferOrderListActionsSurfaceSeed.sql, dbchanges2/Core/2026-08-24b - SalesOrderStatusFilterSurfaceSeed.sql, dbchanges2/Client_Compass/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_CompassCanada/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_Quad/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_Nychh/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Core/2026-08-21 - SalesOrderDecisionSurfacesReseed.sql, dbchanges2/Core/2026-08-24 - RestoreApproveDenyRowActionsVisibility.sql, dbchanges2/Client_Quad/2026-08-24 - ProdPortApprovePoNumberEnabledRule.sql, dbchanges2/Client_Quad/2026-07-21b - ApproveDisabledTooltipPoNumber.sql, dbchanges2/Client_Quad/2026-08-21 - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-08-24 - ProdPortApprovalsGateAndApproveStepTwoRule.sql, dbchanges2/Client_CompassCanada/2026-08-24 - ProdPortApprovalsGateAndApproveStepTwoRule.sql, dbchanges2/Client_Nychh/2026-08-24 - FixInventoryGroupingsUnitsTopologyOverrideIds.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client_Compass/2026-06-25d - SalesOrderSurfaceClientSeed.sql, _underscore/Model/Client/ThemeToken.php, toga25-supply/src/themeConfig.json, dbchanges2/Core/2026-06-25a - SurfaceCoreTables.sql, dbchanges2/Core/2026-06-25b - SurfaceRecordsAndFields.sql, dbchanges2/Core/2026-06-25c - SalesOrderLoginSurfaceSeed.sql, dbchanges2/Core/2026-06-29a - ItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Core/2026-06-29d - VendorItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29e - InventorySurfaceSeed.sql, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Compass/2026-06-30a - SalesOrderDisplaySectionManagerOverrides.sql, dbchanges2/Client_CompassCanada/2026-06-30a - SalesOrderSurfaceManagerOverrides.sql, dbchanges2/Client_Quad/2026-06-30a - SalesOrderSurfaceClientOverrides.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client/2026-06-25b - SurfaceClientSeed.sql, dbchanges2/Client/2026-06-25c - SurfaceClientAcl.sql, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Core/2026-07-17h - Update - ClearApprovalsFilterButtonConfig.sql, dbchanges2/Client_Compass/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Compass/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_Quad/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Quad/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Core/2026-07-20a - Update - HideAdminNotesSectionByDefault.sql, dbchanges2/Core/2026-07-20b - Update - NotesSectionFieldElements.sql, dbchanges2/Client_Compass/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_Compass/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_Quad/2026-07-20a - NotesSectionFieldsOverride.sql, dbchanges2/Core/2026-07-20c - Update - VendorItemsToggleSurfaceSeed.sql, dbchanges2/Client_Compass/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_Compass/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Core/2026-07-20e - RestoreApproveDenyRowActions.sql, dbchanges2/Client_Compass/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Core/2026-07-17 - README - RUN ORDER.md, dbchanges2/Client_Compass/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Compass/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_CompassCanada/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_CompassCanada/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_Quad/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Quad/2026-07-21b - SalesOrderApproveDisabledTooltipTranslation.sql, dbchanges2/Core/2026-07-21a - SalesOrderDecisionSummarySurfaceSeed.sql, dbchanges2/Core/2026-07-21b - SalesOrderDecisionActionSurfaceSeed.sql, dbchanges2/Client_Quad/2026-07-21c - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Core/2026-07-23a - PoNumberDetailFieldValueKey.sql, dbchanges2/Core/2026-07-29a - SalesOrderApprovalDetailsSurfaceSeed.sql, dbchanges2/Core/2026-08-05b - ApprovalDetailsShippingMethodConcatCharge.sql, dbchanges2/Core/2026-08-14a - NoteBadgesVocabularySurface.sql, dbchanges2/Client/2026-08-14a - NoteBadgeThemeTokens.sql, dbchanges2/Client/2026-08-14b - NoteBadgeUserColorFix.sql, dbchanges2/Client_Compass/2026-08-14a - NoteBadgeDelegateThemeTokens.sql, dbchanges2/Client_CompassCanada/2026-08-14a - NoteBadgeDelegateThemeTokens.sql, dbchanges2/Core/2026-08-26a - ServiceRequestRecordHeaderSurfaceSeed.sql, dbchanges2/Core/2026-08-26b - ServiceRequestDetailsSurfaceSeed.sql, dbchanges2/Core/2026-08-28a - TransferOrderSurfaceSeed.sql, dbchanges2/Client/2026-08-28a - TransferOrderStageThemeTokens.sql, dbchanges2/Client_Nychh/2026-08-28b - TransferOrdersNavigationEnable.sql |
7
+ | [Surface Layer Schema (UI presentation/config tables)](features/surface-layer-schema.md) | The persistent schema for the platform-wide **Surface** UI presentation/configuration layer (see the `_underscore` [surface-resolver](../../_underscore/features | dbchanges2/Client_Compass/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql, dbchanges2/Client_CompassCanada/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql, dbchanges2/Core/2026-09-02a - TalosAssistantSurfaceSeed.sql, dbchanges2/Client_Nychh/2026-09-02a - TalosAssistantEnable.sql, dbchanges2/Core/2026-08-31a - SalesOrderDenialDetailsSurfaceSeed.sql, dbchanges2/Client_Compass/2026-08-31a - SalesOrderDenialDetailsReadOnlyRules.sql, toga25-supply/db-migrations/PLAYBOOK.md, toga25-supply/db-migrations/SURFACE-FEATURE-RUNBOOK.md, dbchanges2/Client_Compass/, dbchanges2/Client_CompassCanada/, dbchanges2/Client_Quad/, dbchanges2/Core/2026-08-28b - TransferOrderListActionsSurfaceSeed.sql, dbchanges2/Core/2026-08-24b - SalesOrderStatusFilterSurfaceSeed.sql, dbchanges2/Client_Compass/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_CompassCanada/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_Quad/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_Nychh/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Core/2026-08-21 - SalesOrderDecisionSurfacesReseed.sql, dbchanges2/Core/2026-08-24 - RestoreApproveDenyRowActionsVisibility.sql, dbchanges2/Client_Quad/2026-08-24 - ProdPortApprovePoNumberEnabledRule.sql, dbchanges2/Client_Quad/2026-07-21b - ApproveDisabledTooltipPoNumber.sql, dbchanges2/Client_Quad/2026-08-21 - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-08-24 - ProdPortApprovalsGateAndApproveStepTwoRule.sql, dbchanges2/Client_CompassCanada/2026-08-24 - ProdPortApprovalsGateAndApproveStepTwoRule.sql, dbchanges2/Client_Nychh/2026-08-24 - FixInventoryGroupingsUnitsTopologyOverrideIds.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client_Compass/2026-06-25d - SalesOrderSurfaceClientSeed.sql, _underscore/Model/Client/ThemeToken.php, toga25-supply/src/themeConfig.json, dbchanges2/Core/2026-06-25a - SurfaceCoreTables.sql, dbchanges2/Core/2026-06-25b - SurfaceRecordsAndFields.sql, dbchanges2/Core/2026-06-25c - SalesOrderLoginSurfaceSeed.sql, dbchanges2/Core/2026-06-29a - ItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Core/2026-06-29d - VendorItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29e - InventorySurfaceSeed.sql, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Compass/2026-06-30a - SalesOrderDisplaySectionManagerOverrides.sql, dbchanges2/Client_CompassCanada/2026-06-30a - SalesOrderSurfaceManagerOverrides.sql, dbchanges2/Client_Quad/2026-06-30a - SalesOrderSurfaceClientOverrides.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client/2026-06-25b - SurfaceClientSeed.sql, dbchanges2/Client/2026-06-25c - SurfaceClientAcl.sql, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Core/2026-07-17h - Update - ClearApprovalsFilterButtonConfig.sql, dbchanges2/Client_Compass/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Compass/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_Quad/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Quad/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Core/2026-07-20a - Update - HideAdminNotesSectionByDefault.sql, dbchanges2/Core/2026-07-20b - Update - NotesSectionFieldElements.sql, dbchanges2/Client_Compass/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_Compass/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_Quad/2026-07-20a - NotesSectionFieldsOverride.sql, dbchanges2/Core/2026-07-20c - Update - VendorItemsToggleSurfaceSeed.sql, dbchanges2/Client_Compass/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_Compass/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Core/2026-07-20e - RestoreApproveDenyRowActions.sql, dbchanges2/Client_Compass/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Core/2026-07-17 - README - RUN ORDER.md, dbchanges2/Client_Compass/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Compass/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_CompassCanada/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_CompassCanada/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_Quad/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Quad/2026-07-21b - SalesOrderApproveDisabledTooltipTranslation.sql, dbchanges2/Core/2026-07-21a - SalesOrderDecisionSummarySurfaceSeed.sql, dbchanges2/Core/2026-07-21b - SalesOrderDecisionActionSurfaceSeed.sql, dbchanges2/Client_Quad/2026-07-21c - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Core/2026-07-23a - PoNumberDetailFieldValueKey.sql, dbchanges2/Core/2026-07-29a - SalesOrderApprovalDetailsSurfaceSeed.sql, dbchanges2/Core/2026-08-05b - ApprovalDetailsShippingMethodConcatCharge.sql, dbchanges2/Core/2026-08-14a - NoteBadgesVocabularySurface.sql, dbchanges2/Client/2026-08-14a - NoteBadgeThemeTokens.sql, dbchanges2/Client/2026-08-14b - NoteBadgeUserColorFix.sql, dbchanges2/Client_Compass/2026-08-14a - NoteBadgeDelegateThemeTokens.sql, dbchanges2/Client_CompassCanada/2026-08-14a - NoteBadgeDelegateThemeTokens.sql, dbchanges2/Core/2026-08-26a - ServiceRequestRecordHeaderSurfaceSeed.sql, dbchanges2/Core/2026-08-26b - ServiceRequestDetailsSurfaceSeed.sql, dbchanges2/Core/2026-08-28a - TransferOrderSurfaceSeed.sql, dbchanges2/Client/2026-08-28a - TransferOrderStageThemeTokens.sql, dbchanges2/Client_Nychh/2026-08-28b - TransferOrdersNavigationEnable.sql, dbchanges2/Core/2026-09-03c - ColumnsModalSurfaceSeed.sql, dbchanges2/Core/2026-09-03d - ColumnsModalRetireConfigModal.sql |
8
8
  | [2.0 New-Client Onboarding (manual process)](workflows/client-onboarding.md) | > **A local browser wizard now automates this.** Steps 2–9 below (create DBs, generate Core/API > inserts, append to `Clients_Db.txt`) — plus the dbchanges2 bla | Client/, Client_<Tenant>/, Core/, Logs_Client/ |
9
9
  | [Auditing a client DB that drifted from its models (partially applied module migration)](workflows/client-schema-drift-audit.md) | A recurring 2.0 failure mode: **one client's database drifts from what the PHP models declare**, usually because a `_modules/<module>/` migration was applied to | dbchanges2/Client_Growrk/2026-05-28.sql, dbchanges2/Client_Growrk/2026-08-10c - GrowrkServiceRequestCustomFieldsCatchUp.sql, dbchanges2/Client_Growrk/2026-08-10d - GrowrkServiceRequestTypeAndDispositionSeeds.sql, dbchanges2/_modules/netsuite/2026-07-10a - UnitInventoryFields.sql, dbchanges2/Client_Growrk/2026-08-10 - GrowrkUnitInventoryFieldsCatchUp.sql, dbchanges2/Client_Growrk/2026-08-10b - GrowrkUnitItemDescriptionAcl.sql, dbchanges2/Client_Growrk/_modules.txt |
10
10
  | [Framework branch running AHEAD of schema — environment-wide 1054/EO-1 after a deploy](workflows/framework-branch-ahead-of-schema.md) | A **third** kind of 2.0 schema drift, distinct from the two already documented: nobody's database went backwards — **the code went forwards**. | _underscore/Model/Core/RecordField.php, _underscore/Model/Client/TableViewField.php, _underscore/Model/Client/TableView.php, _underscore/Model/Core/Surface.php, _underscore/Model/Client/Urgency.php, _underscore/Model/Client/RecordFieldSetting.php, _underscore/Model/Client/ClientRecordFieldSetting.php, api2/Component/Api/V2/V2.php, dbchanges2/Core/2026-08-14c - RecordFieldsValidationMetadata.sql, dbchanges2/Client/2026-08-14a - RecordFieldSettingsClientTable.sql, dbchanges2/Client/2026-08-13a - UserNavPreferencesClientTable.sql, dbchanges2/Client/2026-08-13c - PersonaNavVisibilityClientTable.sql |
@@ -278,6 +278,17 @@ concluding a migration is production-safe.
278
278
  **`(id, uuid, dtCreated, messageKey, name, defaultValue)`** — a guess of `slug` / `message`
279
279
  (inferred from a sibling's values row) died on `Unknown column 'slug'`. When in doubt,
280
280
  `SHOW COLUMNS FROM <Table>` costs one round-trip.
281
+ - **⚠ Rewriting a `VALUES` seed as `INSERT … SELECT` silently FLATTENS per-row columns.** The rewrite
282
+ is the normal way to add a `NOT EXISTS` guard, and it is easy to carry one row's value into the
283
+ constant list for every row. Caught 2026-09-03: converting a `Core.SurfaceElements` seed hardcoded
284
+ `'NEUTRAL'` as the `variant` for all 11 elements, losing the `PRIMARY` / `SECONDARY` ones. Nothing
285
+ errors — the enum accepts it. **Diff the rewritten file column-by-column against the original
286
+ `VALUES` rows before running it**, and treat any column that differed between rows as suspect.
287
+ - **⚠ A scalar sub-select for a foreign key writes `NULL` when it misses — it does not fail.**
288
+ `(SELECT id FROM Core.Messages WHERE messageKey = '…')` returns `NULL` if the key was never seeded,
289
+ and a nullable FK column takes it, leaving a structurally broken row that looks inserted. Prefer a
290
+ `JOIN` (so a missing lookup inserts **no row** at all) or add the lookup to the guard's
291
+ `WHERE EXISTS`.
281
292
  - **`Core.RecordFields` has NO `sql` column.** A calculated field's SQL is **not** stored in
282
293
  metadata — it lives in the `_underscore` model as a `FIELD_SQL` static method. Do not go looking
283
294
  for a migration to change a calculated field's expression; there is none. See
@@ -288,7 +299,11 @@ concluding a migration is production-safe.
288
299
  `SEQ_IN_INDEX = 1`**, not on `INDEX_NAME` — an existing index under a different name goes
289
300
  undetected by a name-based guard and you add a duplicate. Found while sizing (and then dropping)
290
301
  the Compass `Users` search indexes. (ajean)
291
-
302
+ - 2026-09-03 — Two silent-failure traps found self-auditing a `Core.SurfaceElements` seed against the
303
+ team rules: (1) rewriting a `VALUES` seed into `INSERT … SELECT` to add the `NOT EXISTS` guard
304
+ **flattened** the per-row `variant` to a constant `'NEUTRAL'`, dropping `PRIMARY`/`SECONDARY` with
305
+ no error; (2) a scalar sub-select used for a message FK writes `NULL` rather than failing when the
306
+ key is missing. Both leave a row that looks inserted and is wrong. (apeterson)
292
307
  - 2026-09-01 — Added the **fan-out rollback rule**: a `Client/` grant migration that fans out across
293
308
  every tenant must use **hardcoded v4 uuid literals**, not the generator, so its rollback
294
309
  (`DELETE … WHERE uuid IN (…)`) can only touch rows that file created. A natural-key rollback on
@@ -6,9 +6,11 @@ project: Database Changes
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-03
9
+ updated: 2026-09-04
10
10
  owners: [jcardinal, apeterson, tcox]
11
11
  files:
12
+ - dbchanges2/Client_Compass/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql
13
+ - dbchanges2/Client_CompassCanada/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql
12
14
  - dbchanges2/Core/2026-09-02a - TalosAssistantSurfaceSeed.sql
13
15
  - dbchanges2/Client_Nychh/2026-09-02a - TalosAssistantEnable.sql
14
16
  - dbchanges2/Core/2026-08-31a - SalesOrderDenialDetailsSurfaceSeed.sql
@@ -101,10 +103,13 @@ files:
101
103
  - dbchanges2/Core/2026-08-28a - TransferOrderSurfaceSeed.sql
102
104
  - dbchanges2/Client/2026-08-28a - TransferOrderStageThemeTokens.sql
103
105
  - dbchanges2/Client_Nychh/2026-08-28b - TransferOrdersNavigationEnable.sql
106
+ - dbchanges2/Core/2026-09-03c - ColumnsModalSurfaceSeed.sql
107
+ - dbchanges2/Core/2026-09-03d - ColumnsModalRetireConfigModal.sql
104
108
  related:
105
109
  - ../../_underscore/features/surface-resolver.md
106
110
  - ../../_underscore/features/sales-order-denial-reason.md
107
111
  - ../../toga25-supply/features/transfer-orders-page.md
112
+ - ../../toga25-supply/features/column-visibility.md
108
113
  ---
109
114
 
110
115
  ## What it is
@@ -348,6 +353,36 @@ Both clients' `c`/`d` overrides and the Core seed were added to `Core/2026-07-17
348
353
  ORDER.md`. Verified working in the UI for both clients. (Boolean toggle/badge labels in this modal
349
354
  resolve through the resolver's new `trueLabel`/`falseLabel` emission — see the resolver doc.)
350
355
 
356
+ ### Restrict-to-Persona field — the seed intended a client opt-in that was NEVER WRITTEN (2026-09-04)
357
+
358
+ The same Core-neutral-default → client-opt-in rule, but a case where only **half** of it shipped.
359
+ `Core/2026-07-17d` seeded the item modal's More-Info **`restrictToPersona`** element (surface
360
+ `item-record-more-info` = **19**, `SurfaceElements` id **57**) `isVisible=0` / `isEnabled=0` with
361
+ `config {valueKey: personaItems, computeValueKey: personaItems}`, intending each client to opt in —
362
+ and **no client override was ever authored**. Verified on prod **and** client-sandbox: **zero**
363
+ `SurfaceOverrides` rows for element 57 in `Client_Compass` or `Client_CompassCanada`. Symptom: the
364
+ item modal's "More Info" section header rendered with **no fields** for both Compass tenants (the
365
+ field showed only in edit mode, which is still JSON-driven — see
366
+ [surface-frontend](../../toga25-supply/features/surface-frontend.md)).
367
+
368
+ - **Fix:** `Client_Compass/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql` and
369
+ `Client_CompassCanada/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql` — client-wide
370
+ (`roleId`/`personaId`/`languageId` NULL) `IS_VISIBLE='1'` + `IS_ENABLED='1'` rows for
371
+ `surfaceId` 19 / `surfaceElementId` 57, `NOT EXISTS`-guarded (re-runnable), Core ids **inlined as
372
+ literals** and **pre-generated v4 UUID literals** (never MySQL `UUID()`) — the cluster-isolation
373
+ house style, same shape as `Client_Compass/2026-08-31a`. Ids 19/57 confirmed **identical on prod
374
+ and client-sandbox** Core before authoring.
375
+ - **This element is resolved by literal id, not id-agnostically, on purpose:** it carries no
376
+ `config.role` / `config.kind` marker to match on, and a `Client_*` file cannot read `Core.*`
377
+ across clusters — so the literal id is the only handle. Re-verify 19/57 against the target Core
378
+ before running this in a new environment.
379
+ - **⚠ Ship-together dependency — the FE goes FIRST (or together); this SQL alone CRASHES the modal.**
380
+ Making the field visible exposes a value the FE could not format until the `personaItems`
381
+ `computeValueKey` was registered in `ItemRecordView`; the unformatted array killed the whole modal
382
+ route with React **error #31**. Same class as the approvals-gate / Approve-step-two pair below.
383
+ - **Run state: `client-sandbox` `Client_Compass` only** (override rows 64/65, 2026-09-04 11:25).
384
+ **Prod not run; `Client_CompassCanada` not run anywhere.**
385
+
351
386
  ## SalesOrder decision-modal surfaces (approve + deny) — shared summary + per-action chrome
352
387
 
353
388
  The approve/deny decision modal's display config was migrated off the per-client
@@ -1292,7 +1327,73 @@ before your own run — that handoff is a snapshot, and the migrations that cons
1292
1327
  uncommitted at the time. See
1293
1328
  [Transfer Orders page](../../toga25-supply/features/transfer-orders-page.md).
1294
1329
 
1330
+ ## A MODAL that needs surface-driven copy becomes its OWN Surface (`columns-modal`, Core 60, 2026-09-03)
1331
+
1332
+ **Rule: never park copy in `config` JSON.** The resolver collects `bundle.messages` from **only** the
1333
+ six `*MessageId` columns on `SurfaceElements`; `config` is passed through untouched and is never
1334
+ scanned. A message key seeded into `config` therefore has no value behind it and the screen renders
1335
+ the raw key. `Core/2026-08-28b` had put the seven `common.columns.modal.*` keys inside the
1336
+ `columnsButton` element's `config.modal` — data that looks right and can never resolve. See
1337
+ [surface-resolver](../../_underscore/features/surface-resolver.md).
1338
+
1339
+ The fix is a **data** fix, not a framework fix: `Core/2026-09-03c - ColumnsModalSurfaceSeed.sql`
1340
+ creates a shared `columns-modal` MODAL surface where **every string rides its own element's
1341
+ `labelMessageId`**, which the existing collector already resolves.
1342
+
1343
+ - **Surface 60** `columns-modal`; **Messages 293–298**; **SurfaceElements 222–232** (11 elements,
1344
+ `config.role` = `shownSection`, `hideAll`, `hiddenSection`, `showAll`, `reorder`, `pinToggle`,
1345
+ `visibilityToggle`, `reset`, `cancel`, `apply`, `close`).
1346
+ - **Elements are matched by `config.role`** — `SurfaceElements` has no `slug` column and the bundle
1347
+ exposes only a uuid as `key`. Same convention as `filterButton` / `columnsButton`.
1348
+ - **Element visibility gates CAPABILITY, not just chrome.** `isVisible = 0` on `pinToggle` removes
1349
+ pinning; on `reorder` removes drag-to-reorder. A tenant switches a feature off from data — the same
1350
+ marker-element idea as the Talos feature switch above, applied inside a shared component.
1351
+
1352
+ ### Split the seed from the cleanup so the rollout is order-independent
1353
+
1354
+ Retiring the dead `config.modal` is a **second** file, `Core/2026-09-03d -
1355
+ ColumnsModalRetireConfigModal.sql`, which must run **only after toga25-supply is deployed**. With the
1356
+ two split, either order is safe: seed-then-deploy leaves a harmless unused `config`, and
1357
+ deploy-then-seed is the normal path. Putting both in one file would make the migration a
1358
+ deploy-blocker.
1359
+
1360
+ > ⚠ Neither file has been run on **any** environment yet (as of 2026-09-03).
1361
+
1362
+ ### Id block consumed — and PROD did not match sandbox
1363
+
1364
+ The first id choice **collided on production**: `Messages` 289–294 had four already taken
1365
+ (`MAX(id)` = 292) and `Surfaces` **59 was taken**. Renumbered after querying the **production Core
1366
+ reader**. Sandbox Core differed substantially from prod — surface 59 was free on prod but taken on
1367
+ sandbox, and the `common.columns.modal.*` keys sit at **different ids per environment**.
1368
+
1369
+ This is the third confirmation of the rule above: verify a reserved block is free on **every**
1370
+ environment and take the **highest** `MAX(id)+1`, never the one in front of you, and never the repo.
1371
+
1372
+ **Handoff published (verify before your own run):** next `Core.Surfaces.id` = **61**, next
1373
+ `Core.SurfaceElements.id` = **233**, next `Core.Messages.id` = **299**. Both consuming files were
1374
+ still unrun at the time, so this is a reservation, not a deployed state.
1375
+
1295
1376
  ## Change history
1377
+ - 2026-09-04 — Added the **restrict-to-persona opt-in pair** (`Client_Compass/2026-09-04a` +
1378
+ `Client_CompassCanada/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql`): client-wide
1379
+ `IS_VISIBLE`+`IS_ENABLED` overrides on `surfaceId` **19** / `surfaceElementId` **57**, literal Core
1380
+ ids + v4 UUID literals + `NOT EXISTS` guards. Recorded the failure mode they fix — `Core/2026-07-17d`
1381
+ seeded the element OFF **intending** a per-client opt-in and **no client override was ever written**
1382
+ (zero rows in both Compass tenants, prod and sandbox), so the item modal's More Info section
1383
+ rendered empty. Also recorded the **FE-first ship order**: the override alone exposes an
1384
+ unformatted value and crashes the modal (React error #31). Run on **client-sandbox `Client_Compass`
1385
+ only**; prod and Canada pending. (tcox)
1386
+ - 2026-09-03 — Recorded the rule that **a modal needing surface-driven copy becomes its own Surface**:
1387
+ the resolver reads messages only from `*MessageId` columns and never from `config`, so
1388
+ `Core/2026-08-28b`'s `config.modal` keys could never resolve and the Columns modal rendered raw
1389
+ keys. `Core/2026-09-03c` seeds a shared `columns-modal` MODAL surface (**60**, Messages **293–298**,
1390
+ SurfaceElements **222–232**) with one message column per string; elements are matched by
1391
+ `config.role`, and `isVisible = 0` on `pinToggle` / `reorder` gates those **capabilities** per
1392
+ tenant. Cleanup of the dead `config.modal` is deliberately a separate file (`Core/2026-09-03d`) that
1393
+ runs only after the FE deploy, so the rollout is safe in either order. The first id block collided
1394
+ on **production** (Messages `MAX(id)` = 292, Surfaces 59 taken) while sandbox looked free — third
1395
+ confirmation to verify a block on every environment. Handoff: next Surfaces = **61**,
1396
+ SurfaceElements = **233**, Messages = **299** (both files still unrun everywhere). (apeterson)
1296
1397
  - 2026-09-03 — Flagged that the Compass / Compass Canada stacked-Total `CONFIG` override must **not**
1297
1398
  gain a `"suffix":"+"`: blox renders a field `suffix` unconditionally while the stacked lease line
1298
1399
  auto-hides on null/0, so a seeded `+` dangles on non-lease orders. The joiner is conditional FE code
@@ -6,7 +6,7 @@ project: TOGa Blox
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-31
9
+ updated: 2026-09-03
10
10
  owners: [apeterson, tcox]
11
11
  files:
12
12
  - toga-blox/src/components/BaseButton
@@ -135,6 +135,28 @@ declares, on class names `getStickyClass` never emits. Dead rules like this look
135
135
  someone greps for the divider. Both were deleted 2026-08-31; grep the emitting code before trusting a
136
136
  CSS rule, and check the token it names actually exists in a host theme.
137
137
 
138
+ ### 6. Button HEIGHT is a literal with no token — a toolbar cannot be sized from the host theme
139
+
140
+ Defect 4 is about swapping variants; this is the shape of the same problem for a consumer who never
141
+ swaps anything. Measured 2026-09-03 in
142
+ `dist/components/BaseButton/BaseButton.module.css`:
143
+
144
+ - `.secondaryBtnAction` hard-codes **`height: 26px`** — **no `var(--…)`, so no host token can reach
145
+ it**.
146
+ - `.primaryBtn` declares **no height at all** (computes to ~25px).
147
+ - `font-size` **is** tokened (14px default).
148
+
149
+ **Consumer symptom.** One page toolbar renders its buttons at three different heights, and the
150
+ developer cannot fix it from `themeConfig.json` — the number they need to change is not exposed.
151
+ Nothing in the theme layer hints that geometry is off-limits, so the time is lost hunting the app's
152
+ own CSS.
153
+
154
+ **Fix direction.** Same as defect 5: convert both to `var(--baseButton-height, <existing literal>)`
155
+ (and give `.primaryBtn` the same base height), which is non-breaking because the fallback preserves
156
+ today's look. Until then a consumer must own the chrome: `toga25-supply` built its own
157
+ `ToolbarButton` rather than retheme tokens shared by ~all buttons in the app — see
158
+ [toolbar button chrome](../../toga25-supply/features/toolbar-button-chrome.md).
159
+
138
160
  ## Already-known authoring items (carried over from §22)
139
161
 
140
162
  These are long-standing and were previously recorded only as consumer-side advice:
@@ -168,6 +190,12 @@ These are long-standing and were previously recorded only as consumer-side advic
168
190
  time**, with no PR. Sequence auth fixes deliberately.
169
191
 
170
192
  ## Change history
193
+ - 2026-09-03 — Added styling defect **6: button height is a literal with no token**.
194
+ `.secondaryBtnAction` hard-codes `height: 26px` and `.primaryBtn` sets no height at all, so a
195
+ consumer cannot size a toolbar from the host theme and the cause is invisible from the theme layer.
196
+ Found building toga25-supply's page toolbar (three buttons, three heights); compensated by an
197
+ app-owned `ToolbarButton`, not by retheming shared tokens. Fix direction is defect 5's:
198
+ `var(--baseButton-height, <literal>)`, non-breaking via the fallback. (apeterson)
171
199
  - 2026-08-31 — Added styling defect **5: hardcoded visual values in the shared stylesheets cannot be
172
200
  removed by a consumer** — a literal has no host-token escape hatch. Worked example: the `supply`
173
201
  table card shadow, which a toga25-supply design review needed gone; converted to
@@ -6,7 +6,7 @@
6
6
  | [Action-Button Rule Engine (Flag / Rule grammar)](features/action-button-rule-engine.md) | A declarative, fully config-driven rule engine that resolves the boolean-ish flags (`isEnabled`, `isVisible`, `isComplete`) on SalesOrder action-button options. | toga25-supply/src/pages/SalesOrders/helpers/evaluateEnableRule.ts, toga25-supply/src/pages/SalesOrders/helpers/evaluateEnableRule.test.ts, toga25-supply/src/pages/SalesOrders/helpers/buildPatchedTenantFields.ts, toga25-supply/src/layout/RecordApprovalModal/view/ApprovalFlowDetailInputs.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/viewModel/FIELDS/COMPASS/approvalActionFields.json, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/viewModel/FIELDS/COMPASSCANADA/approvalActionFields.json, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/viewModel/useApprovalModalViewModel.tsx |
7
7
  | [Client API-Fetch Fields (useClientApiFields / apiFields.json)](features/client-api-fetch-fields.md) | The mechanism that resolves a client's **API-fetch projection** — which `fields` / `ojoin` / `join` / `where` to request from the TOGa API for a given page — fr | toga25-supply/src/fieldsConfig/resolveApiConfig.ts, toga25-supply/src/fieldsConfig/useClientApiFields.ts, toga25-supply/src/fieldsConfig/index.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/FIELDS/apiFields.json, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx, toga25-supply/src/layout/ItemRecordModalLayout/viewModel/FIELDS/apiFields.json, toga25-supply/src/layout/ItemRecordModalLayout/viewModel/useItemRecordModalViewModel.tsx |
8
8
  | [Client-Configurable Fields (useClientFields / fieldsConfig)](features/client-configurable-fields.md) | The mechanism for config that **varies by client** (or client × role) — field overrides, filter buttons, group-by options, column pickers, layout toggles — with | toga25-supply/src/fieldsConfig/index.ts, toga25-supply/src/fieldsConfig/resolveRole.ts, toga25-supply/src/globalFieldsConfig/clientLoginFields.json, toga25-supply/src/fieldsConfig/useClientFields.ts, toga25-supply/src/pages/SalesOrders/viewModel/FIELDS/, toga25-supply/src/pages/Inventory/viewModel/FIELDS/, toga25-supply/src/pages/Inventory/README.md, toga25-supply/src/layout/ItemRecordModalLayout/viewModel/FIELDS/, toga25-supply/src/layout/VendorItemRecordModalLayout/viewModel/FIELDS/, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderTopBar.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/FIELDS/ |
9
- | [Column Visibility (URL-driven show/hide columns)](features/column-visibility.md) | A "Columns" header button that opens a modal listing every column from the table meta, lets the user show/hide columns, adjusts the table live, and persists the | toga25-supply/src/components/ColumnVisibilityModal/, toga25-supply/src/surface/useColumnVisibilityModalConfig.ts, toga25-supply/src/surface/index.ts, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx, toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableData.tsx, dbchanges2/Core/2026-08-28b - TransferOrderListActionsSurfaceSeed.sql |
9
+ | [Column State (URL-driven show/hide, order & pinning)](features/column-visibility.md) | A "Columns" header button that opens a modal listing every column from the table meta, lets the user show/hide columns, adjusts the table live, and persists the | toga25-supply/src/components/ColumnVisibilityModal/, toga25-supply/src/surface/useColumnVisibilityModalConfig.ts, toga25-supply/src/surface/index.ts, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx, toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableData.tsx, dbchanges2/Core/2026-08-28b - TransferOrderListActionsSurfaceSeed.sql, toga25-supply/src/hooks/useColumnState.ts, dbchanges2/Core/2026-09-03c - ColumnsModalSurfaceSeed.sql, dbchanges2/Core/2026-09-03d - ColumnsModalRetireConfigModal.sql |
10
10
  | [Force Logout on Deployment (useDeploymentGuard)](features/force-logout-on-deployment.md) | On large deployments the backend bumps the Core parameter `META_LAST_REFRESH_DATETIME`. | toga25-supply/src/hooks/useDeploymentGuard.tsx, toga25-supply/src/App.tsx |
11
11
  | [Meta-Driven Page & Table Setup](features/meta-driven-table-data.md) | A page in this app is **meta-driven end to end**: the page view model fetches *page meta* (labels, sections, ACL) and *table meta* (the columns/fields + table s | toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/TransferOrders/viewModel/useTransferOrdersPageViewModel.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableState.ts, toga25-supply/src/hooks/useTablePageMeta.ts, toga-blox-npm/dist/hooks/useFetchPageMeta.d.ts, toga-blox-npm/dist/hooks/useFetchTablePageMeta.d.ts, toga-blox-npm/dist/hooks/useAssignTableFieldLabels.d.ts, toga-blox-npm/dist/components/Table/hooks/useTableData.d.ts |
12
12
  | [Persisted React Query cache (localStorage `supply-chain-query-cache`)](features/persisted-query-cache.md) | `localStorage["supply-chain-query-cache"]` is **not a hand-written cache**. | toga25-supply/src/App.tsx, toga25-supply/src/contexts/AuthContext.tsx, toga25-supply/src/hooks/useDeploymentGuard.tsx, toga25-supply/src/fieldsConfig/useClientFields.ts, toga25-supply/src/surface/useFetchSurfaceMeta.ts, toga25-supply/src/hooks/useCurrentUser.ts, toga25-supply/src/hooks/useStatusTypeValues.ts, toga25-supply/src/surface/useStatusColors.ts, toga25-supply/vite.config.ts |
@@ -15,6 +15,7 @@
15
15
  | [SSO redirect & public-vs-user session gating (useAuthenticationFlow)](features/sso-redirect-and-session-gating.md) | How 2.5 Supply decides, on every navigation, whether an anonymous visitor should be bounced to their client's SSO IdP instead of the local `/login` form. | toga25-supply/src/hooks/useAuthenticationFlow.ts, toga25-supply/src/routes.tsx, toga25-supply/src/contexts/AuthContext.tsx, toga25-supply/src/api/api.ts |
16
16
  | [Surface Frontend (DB-driven UI consumption, src/surface/)](features/surface-frontend.md) | The frontend consumer of the platform-wide Surface layer — DB-driven UI config fetched from `GET /v2/surfaces/meta?slug=<slug>` instead of statically-imported J | toga25-supply/src/layout/RecordApprovalModal/helpers/stackedCurrencyJoiner.ts, toga25-supply/src/layout/RecordApprovalModal/helpers/stackedCurrencyJoiner.test.ts, toga25-supply/src/App.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/DenialBanner.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/SalesOrderRecordModal.module.css, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/SalesOrderApprovalModalsLayout.tsx, toga25-supply/src/layout/RecordApprovalModal/RecordApprovalModalLayout.tsx, toga25-supply/src/layout/RecordApprovalModal/view/ApprovalTimelineView.tsx, toga25-supply/src/layout/RecordApprovalModal/ApprovalModal.module.css, toga25-supply/src/utils/formatDateTime.ts, toga25-supply/src/utils/index.ts, toga25-supply/src/surface/evaluateSurfaceRule.ts, toga25-supply/src/layout/RecordApprovalModal/, toga25-supply/src/contexts/AuthContext.tsx, toga25-supply/src/surface/applyColSpan.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/sectionRenderers.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getVisibleSections.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/SalesOrderApprovalSummaryGrid.tsx, toga25-supply/src/surface/useFetchSurfaceMeta.ts, toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/VendorItems/viewModel/useVendorItemsPageViewModel.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/pages/Bundles/viewModel/useBundlesPageViewModel.tsx, toga25-supply/src/layout/ItemFulfillmentModal/useItemFulfillmentModalViewModel.tsx, toga25-supply/src/pages/SalesOrders/helpers/surfaceBundleToTenantFields.ts, toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/ServiceRequestRecordModalLayout.tsx, toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/view/ServiceRequestsView.tsx, toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/viewModel/useServiceRequestRecordModalLayoutModel.tsx, toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/viewModel/FIELDS/apiFields.json, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderTopBar.tsx, toga25-supply/src/pages/SalesOrders/helpers/buildPatchedTenantFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/SalesOrderView.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/SalesOrderSummaryGrid.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getDetailSections.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderNotesSection.tsx, toga25-supply/src/pages/SalesOrders/helpers/cleanOrder.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/AdminNotesSection.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getAdminNotes.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/index.ts, toga25-supply/src/surface/evaluateSurfaceRule.ts, toga25-supply/src/surface/resolveElementState.ts, toga25-supply/src/pages/SalesOrders/helpers/evaluateEnableRule.ts, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrderRowRecordState.ts, toga25-supply/src/surface/actionRegistry.ts, toga25-supply/src/surface/componentRegistry.tsx, toga25-supply/src/surface/SurfaceActionBar.tsx, toga25-supply/src/surface/SurfaceSection.tsx, toga25-supply/src/surface/resolve.ts, toga25-supply/src/surface/types.ts, toga25-supply/src/surface/index.ts, toga25-supply/src/pages/Login/LoginPage.tsx, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx, toga25-supply/src/pages/SalesOrders/view/SurfaceRowActions.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrderVip.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderTopBar.tsx, toga25-supply/src/pages/Items/ItemsPage.tsx, toga25-supply/src/pages/Items/viewModel/useItemsPageViewModel.tsx, toga25-supply/src/pages/VendorItems/VendorItemsPage.tsx, toga25-supply/src/pages/VendorItems/viewModel/useVendorItemsPageViewModel.tsx, toga25-supply/src/pages/Inventory/Inventory.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/pages/Inventory/viewModel/FIELDS/index.ts, toga25-supply/src/fieldsConfig/index.ts, toga25-supply/src/layout/ItemRecordModalLayout/helpers/surfaceBundleToItemFields.ts, toga25-supply/src/layout/ItemRecordModalLayout/helpers/index.ts, toga25-supply/src/layout/ItemRecordModalLayout/viewModel/useItemRecordModalViewModel.tsx, toga25-supply/src/layout/ItemRecordModalLayout/ItemRecordModalLayout.tsx, toga25-supply/src/layout/ItemRecordModalLayout/components/ItemRecordView.tsx, toga25-supply/src/surface/useStatusColors.ts, toga25-supply/src/surface/SurfaceHeader.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/helpers/surfaceBundlesToDecisionFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/viewModel/useApprovalModalViewModel.tsx |
17
17
  | [Talos Integration (AppLayout host, live Aegra streaming, LangGraph→blox mapper)](features/talos-integration.md) | toga25-supply is the first host of the shared blox [Talos assistant](../../toga-blox/features/talos-assistant.md). | toga25-supply/src/api/talos.ts, toga25-supply/src/hooks/useTalosSession.ts, toga25-supply/src/hooks/useTalosThreads.ts, toga25-supply/src/hooks/useTalosThreads.test.ts, toga25-supply/src/hooks/useTalosSurface.ts, toga25-supply/src/hooks/useTalosSurface.test.ts, toga25-supply/src/App.tsx, toga25-supply/db-migrations/PLAYBOOK.md, dbchanges2/Core/2026-09-02a - TalosAssistantSurfaceSeed.sql, dbchanges2/Client_Nychh/2026-09-02a - TalosAssistantEnable.sql, toga25-supply/src/providers/TalosStreamProvider.tsx, toga25-supply/src/utils/talosMessages.ts, toga25-supply/src/utils/talosMessages.test.ts, toga25-supply/src/layout/AppLayout/AppLayout.tsx, toga25-supply/src/components/Header/Header.tsx, toga25-supply/src/components/Header/Header.module.css, toga25-supply/src/index.css, toga25-supply/src/assets/talos-owl.png |
18
+ | [Toolbar button chrome (ToolbarButton) — and why blox BaseButton can't be resized from the theme](features/toolbar-button-chrome.md) | `ToolbarButton` is toga25-supply's **own** chrome for page-toolbar buttons (Refresh, Reset, Columns, Group by). | toga25-supply/src/components/ToolbarButton/ToolbarButton.tsx, toga25-supply/src/components/ToolbarButton/ToolbarButton.module.css, toga25-supply/src/surface/SurfaceActionBar.tsx, toga25-supply/src/themeConfig.json |
18
19
  | [Transfer Orders page (TableView → Core surfaces → React page + record modal)](features/transfer-orders-page.md) | The Transfer Orders screen — list + read-only record modal — built end to end on 2026-08-28 from a Claude Design prototype. | toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useCreateTransferOrder.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/PanelSelect.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/ContactSelect.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/SelectTargetLocationModal.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/TransferItemPickerTable.tsx, _underscore/Model/Client/TransferOrder.php, _underscore/Model/Client/TransferOrderNote.php, _underscore/Model/Client/TransferOrderNoteType.php, dbchanges2/Client/2026-09-01b - TransferOrderNotesTables.sql, dbchanges2/Client/2026-09-01c - TransferOrderContactCreatedByAndDescription.sql, dbchanges2/Core/2026-09-01c - TransferOrderNotesRecords.sql, dbchanges2/Core/2026-09-01d - TransferOrderContactCreatedByAndDescriptionRecordFields.sql, toga25-supply/src/pages/TransferOrders/TransferOrders.tsx, toga25-supply/src/pages/TransferOrders/hooks/useTransferOrdersTableState.ts, toga25-supply/src/pages/TransferOrders/viewModel/useTransferOrdersPageViewModel.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrdersTableLayout/TransferOrdersTableLayout.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/TransferOrderRecordModalLayout.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/hooks/useTransferOrderRecord.ts, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/viewModel/useTransferOrderRecordModalLayoutModel.ts, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/view/TransferOrderRecordView.tsx, toga25-supply/src/pages/TransferOrders/helpers/buildInventoryPurchaseOrderUrl.ts, toga25-supply/src/pages/TransferOrders/TransferOrder.module.css, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/CreateTransferOrderModal.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/index.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/usePurchaseOrderItemRows.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTargetLocationOptions.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTransferSourceLocation.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useLocationContacts.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useCreateLocationContact.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/AddContactModal.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/TransferNotesField.tsx, dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql, toga25-supply/src/surface/useColumnVisibilityModalConfig.ts, toga25-supply/src/routes.tsx, dbchanges2/Core/2026-08-28a - TransferOrderSurfaceSeed.sql, dbchanges2/Core/2026-08-28b - TransferOrderListActionsSurfaceSeed.sql, dbchanges2/Client/2026-08-28a - TransferOrderStageThemeTokens.sql, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql, dbchanges2/Client_Nychh/2026-08-28a - TransferOrdersTableView.sql, dbchanges2/Client_Nychh/2026-08-28b - TransferOrdersNavigationEnable.sql, dbchanges2/Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql, dbchanges2/Core/2026-09-03a - TransferOrderStatusBadgeRecordFields.sql, dbchanges2/Client_Nychh/2026-09-03a - TransferOrdersStatusBadge.sql, dbchanges2/Core/2026-09-03b - InventoryCreateTransferOrderAction.sql, dbchanges2/Client_Nychh/2026-09-03b - InventoryCreateTransferOrderShow.sql, toga25-supply/src/surface/useStatusColors.ts, toga25-supply/src/pages/Inventory/Inventory.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/layout/ItemFulfillmentModal/useItemFulfillmentModalViewModel.tsx, _underscore/Model/Client/TransferOrderItem.php |
19
20
  | [AWS Amplify Multi-Environment Deployment](workflows/amplify-deployment.md) | How `toga25-supply` deploys to **all** of its environments on AWS Amplify from a **single shared `amplify.yml`**. | toga25-supply/amplify.yml, toga25-supply/src/api/api.ts, toga25-supply/src/hooks/useAuthenticationFlow.ts, toga25-supply/vite.config.ts, toga25-supply/package.json, toga25-supply/.env.development |
20
21
  | [Cypress Testing Harness (multi-tenant, fully stubbed)](workflows/cypress-testing.md) | The Cypress harness for `toga25-supply`: an **e2e** project and a **component** project, plus a stub layer that answers **every** `/v2` call so no test touches | toga25-supply/cypress.config.ts, toga25-supply/cypress/README.md, toga25-supply/cypress/tsconfig.json, toga25-supply/cypress/support/e2e.ts, toga25-supply/cypress/support/commands.ts, toga25-supply/cypress/support/component.tsx, toga25-supply/cypress/support/component-index.html, toga25-supply/cypress/support/tenants.ts, toga25-supply/cypress/support/api/stubApi.ts, toga25-supply/cypress/support/api/envelope.ts, toga25-supply/cypress/support/api/users.ts, toga25-supply/cypress/support/api/surfaces.ts, toga25-supply/cypress/support/api/session.ts, toga25-supply/cypress/e2e/login.cy.ts, toga25-supply/cypress/e2e/emptyNavigation.cy.ts, toga25-supply/cypress/component/harness.cy.tsx, toga25-supply/cypress/fixtures/tenants/COMPASS/surfaces/navigation.json, toga25-supply/cypress/fixtures/tenants/COMPASSCANADA/surfaces/navigation.json, toga25-supply/cypress/fixtures/tenants/NYCHH/surfaces/navigation.json, toga25-supply/cypress/fixtures/tenants/QUAD/surfaces/navigation.json, toga25-supply/scripts/cypress.mjs, toga25-supply/tsconfig.node.json, toga25-supply/package.json |
@@ -1,12 +1,12 @@
1
1
  ---
2
- title: Column Visibility (URL-driven show/hide columns)
2
+ title: Column State (URL-driven show/hide, order & pinning)
3
3
  framework: "2.0"
4
4
  repo: toga25-supply
5
5
  project: TOGa 2.5 Supply
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-28
9
+ updated: 2026-09-03
10
10
  owners: [apeterson]
11
11
  files:
12
12
  - toga25-supply/src/components/ColumnVisibilityModal/
@@ -16,6 +16,9 @@ files:
16
16
  - toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx
17
17
  - toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableData.tsx
18
18
  - dbchanges2/Core/2026-08-28b - TransferOrderListActionsSurfaceSeed.sql
19
+ - toga25-supply/src/hooks/useColumnState.ts
20
+ - dbchanges2/Core/2026-09-03c - ColumnsModalSurfaceSeed.sql
21
+ - dbchanges2/Core/2026-09-03d - ColumnsModalRetireConfigModal.sql
19
22
  related:
20
23
  - ../architecture.md
21
24
  - client-configurable-fields.md
@@ -23,6 +26,8 @@ related:
23
26
  - surface-frontend.md
24
27
  - transfer-orders-page.md
25
28
  - ../../dbchanges2/features/surface-layer-schema.md
29
+ - ../../_underscore/features/surface-resolver.md
30
+ - toolbar-button-chrome.md
26
31
  ---
27
32
 
28
33
  ## What it is
@@ -109,6 +114,77 @@ copy from the **Surface layer**, under **shared `common.columns.modal.*` `Core.M
109
114
  `items.columns.modal.*` / `salesOrder.columns.modal.*` can fold onto the common keys + this hook.
110
115
  Those older keys were deliberately left alone.
111
116
 
117
+ ## The modal is its own MODAL Surface — `columns-modal`, Core surface 60 (2026-09-03)
118
+
119
+ The 2026-08-28 approach above **could never work**, and the reason is structural: the resolver builds
120
+ `bundle.messages` from **only** the six `*MessageId` columns on `SurfaceElements` and **never scans
121
+ `config` JSON**, so the `common.columns.modal.*` keys parked in the `columnsButton` element's
122
+ `config.modal` shipped with no values behind them. The screen rendered the raw key
123
+ (`common.columns.modal.apply` as the Apply label). See
124
+ [surface-resolver](../../_underscore/features/surface-resolver.md).
125
+
126
+ **Decision: a modal that needs surface-driven copy becomes its own Surface** — not a `config` blob on
127
+ the button that opens it, and not an `_underscore` change to scan `config` (that would widen the
128
+ message-resolution contract for every surface in the platform).
129
+
130
+ - **`columns-modal`** (`renderType` MODAL, **Core surface id 60**) is **shared by every list screen**.
131
+ - Every string rides a real `*MessageId` on its own `SurfaceElement`, so the **existing** collector
132
+ resolves it — **zero `_underscore` change**.
133
+ - **Side benefit: element visibility gates capability.** `isVisible = 0` on the `pinToggle` element
134
+ removes pinning; on `reorder` removes drag-to-reorder. A tenant turns a feature off from data.
135
+ - **Elements are matched by `config.role`.** `SurfaceElements` has no `slug` column and the bundle
136
+ exposes only a uuid as `key`, so `config.role` is the join — the same convention `filterButton` /
137
+ `columnsButton` already use.
138
+
139
+ **Seed contents** (`Core/2026-09-03c - ColumnsModalSurfaceSeed.sql`): Surface **60**, Messages
140
+ **293–298**, SurfaceElements **222–232** — 11 elements, roles `shownSection`, `hideAll`,
141
+ `hiddenSection`, `showAll`, `reorder`, `pinToggle`, `visibilityToggle`, `reset`, `cancel`, `apply`,
142
+ `close`.
143
+
144
+ **Two migrations on purpose, and the order matters.** `Core/2026-09-03d -
145
+ ColumnsModalRetireConfigModal.sql` removes the now-dead `config.modal` and must run **only after
146
+ toga25-supply is deployed**. Splitting them makes the rollout safe in either order.
147
+
148
+ > ⚠ Neither seed has been run on **any** environment yet (as of 2026-09-03).
149
+
150
+ ## `useColumnState` — visibility, ORDER and PINNING all ride the same `cols` param
151
+
152
+ `src/hooks/useColumnState.ts` replaces a ~60-line block that had been copy-pasted into **six** page
153
+ view models.
154
+
155
+ **Grammar:** `cols=*number,requestedDate,status`
156
+ - position in the list **is** the column order,
157
+ - a leading **`*`** marks the column **pinned** (sticky-left).
158
+
159
+ **Why one param and not three.** blox's `useTableData` strips exactly `cols` (and the slug) from the
160
+ data query key. Any *new* param name would refetch the whole table on every reorder until blox was
161
+ changed, published and re-pinned. Two more reasons the grammar is worth it: old links keep working (a
162
+ list with no `*` pins nothing), and `*` is one of the few characters `URLSearchParams` does **not**
163
+ percent-encode, so the URL stays readable (`!` would serialize as `%21`).
164
+
165
+ **No toga-blox change was needed** for order or pinning: `buildTanstackColumns` maps `fields` in
166
+ **array order** and reads `meta.sticky`, so reordering the display meta's `fields` array reorders the
167
+ table.
168
+
169
+ **Still required:** the remount key (see gotchas) and keeping **fetch meta full / display meta
170
+ adjusted** — `getDataTableData` builds its API field list from `meta.fields`, so reordering or
171
+ filtering the *fetch* meta drops real data.
172
+
173
+ ### "Reset to default" — derive defaults from the META, never from the URL
174
+
175
+ Reset did nothing, because it rebuilt the option list from `columns`, which is itself derived from the
176
+ URL — so it restored the current state. The hook now exposes **`defaultColumnOptions`** derived from
177
+ the table meta alone:
178
+
179
+ | default | source |
180
+ |---|---|
181
+ | order | position in `meta.fields` |
182
+ | visible | `field.isVisible` |
183
+ | pinned | `field.sticky === "left"` |
184
+
185
+ Applying exactly the defaults now **deletes** the `cols` param instead of writing it. So Reset → Apply
186
+ leaves a clean URL, and a column added to the meta later still shows up for that user.
187
+
112
188
  ## Gotchas
113
189
 
114
190
  - **No hardcoded copy in the component** — every user-facing string (incl. Close `aria-label`) is a
@@ -120,8 +196,13 @@ copy from the **Surface layer**, under **shared `common.columns.modal.*` `Core.M
120
196
  change doesn't change `row` → stale cells. Symptom: **toggling off removes the header but leaves the
121
197
  body column.** `key={columnVisibilityKey}` forces a fresh table instance — the only fix without
122
198
  editing `node_modules`. Real upstream fix: repair that memo in toga-blox.
123
- - **Don't shrink the fetch meta** — adjust `isVisible` only on the table meta; `getDataTableData`
124
- builds its field list from `meta.fields`.
199
+ - **Don't shrink OR REORDER the fetch meta** — adjust `isVisible`/order/`sticky` only on the display
200
+ meta; `getDataTableData` builds its API field list from `meta.fields`, so touching the fetch meta
201
+ drops real data.
202
+ - **No copy in `config` JSON.** The resolver never reads message keys out of `config` — a modal that
203
+ needs surface-driven copy needs its own Surface with one `*MessageId` per string.
204
+ - **Match surface elements by `config.role`, not by key** — `SurfaceElements` has no `slug` and the
205
+ bundle's `key` is a uuid.
125
206
  - **`cols` must be stripped from the data query** or every toggle refetches.
126
207
  - **`has("cols")` vs truthiness** — empty `cols` is a real "hide all" state, not "absent".
127
208
  - **Checkbox can't be a nested `<button>`** — the row is the clickable `<button>`; the checkbox is a
@@ -130,6 +211,22 @@ copy from the **Surface layer**, under **shared `common.columns.modal.*` `Core.M
130
211
  meta later won't appear for an old link until Reset (intended).
131
212
 
132
213
  ## Change history
214
+ - 2026-09-03 — The modal became **its own MODAL Surface** (`columns-modal`, **Core 60**; Messages
215
+ 293–298, SurfaceElements 222–232, `Core/2026-09-03c`). The 08-28 approach was unworkable: the
216
+ resolver only collects messages from `*MessageId` columns and **never scans `config`**, so the
217
+ `common.columns.modal.*` keys parked in `config.modal` had no values and the Apply button rendered
218
+ its raw key. Rejected teaching the resolver to scan `config` — that widens the contract for every
219
+ surface. Elements are matched by `config.role`; `isVisible = 0` on `pinToggle` / `reorder` now gates
220
+ those capabilities per tenant. Retiring the dead `config.modal` is a **separate** migration
221
+ (`Core/2026-09-03d`) that must run only **after** the FE deploy. Added **`useColumnState`**
222
+ (`src/hooks/`), replacing a ~60-line block duplicated across six view models: column **order** and
223
+ **pinning** now ride the same `cols` param (`cols=*number,requestedDate` — position = order, leading
224
+ `*` = pinned), chosen because blox's `useTableData` strips exactly `cols` from the query key, old
225
+ links still work, and `*` is not percent-encoded. Fixed **Reset to default**, which restored the
226
+ current state because it rebuilt options from the URL; defaults now come from the table meta only
227
+ (`defaultColumnOptions`), and applying the defaults deletes the `cols` param. Open: neither seed has
228
+ run anywhere yet, and `ColumnVisibilityModal` still has **no tests** (drag-reorder and the pin/hide
229
+ guards are untested). (apeterson)
133
230
  - 2026-08-28 — Modal copy moved from per-page hardcoded strings / client `FIELDS` to **shared
134
231
  `common.columns.modal.*` `Core.Messages`** (`Core/2026-08-28b`), so a tenant translates it once for
135
232
  every list screen instead of per record type — the old strings sat in the bundle where
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-03
9
+ updated: 2026-09-04
10
10
  owners: [apeterson, tcox, jcardinal]
11
11
  files:
12
12
  - toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/hooks/usePurchaseOrderDetails.ts
@@ -323,6 +323,14 @@ Scaffold per the `ItemRecordModalLayout` pattern (`src/layout/ItemRecordModalLay
323
323
  sees the slide-up animation replay the moment the data arrives. Keep a single modal element and
324
324
  switch only its **children** on the fetch state. Same defect and same fix on the ServiceRequest and
325
325
  Transfer Order modals.
326
+ - **The item modal's VIEW is Surface-driven while its EDIT is still JSON (Pattern 6).** A field
327
+ that appears only after clicking **Edit** — and vanishes again on Cancel — is a **missing client
328
+ `SurfaceOverride`** on a Core-default-OFF element, not a data or fetch bug. The section header
329
+ then renders with no fields under it. Hit 2026-09-04 on **More Info → "Restrict to Persona"** for
330
+ both Compass tenants. The rule, the `computeValueKey` registry trap it exposed (raw object array →
331
+ React **error #31**, shown as the router's "Unexpected Application Error!" since the app has no
332
+ top-level `ErrorBoundary`), and the FE-first deploy order are in
333
+ [surface-frontend](surface-frontend.md).
326
334
  - **A record's child rows may need a SECOND request.** Children nest inside the record response only
327
335
  when `Core.InherentRecordChildren` declares them — `sales-orders` does, `transfer-orders` does
328
336
  **not**. When they do not, fetch the nested route (`/{record}/{uuid}/{child-route}`) and read the
@@ -331,6 +339,10 @@ Scaffold per the `ItemRecordModalLayout` pattern (`src/layout/ItemRecordModalLay
331
339
  [Transfer Orders page](transfer-orders-page.md).
332
340
 
333
341
  ## Change history
342
+ - 2026-09-04 — Added the Pattern 6 gotcha that the **item modal's VIEW is Surface-driven while its
343
+ EDIT is still JSON**: a field visible only in edit mode means a missing client `SurfaceOverride`,
344
+ not a data bug (found on Compass's "Restrict to Persona" field). Detail in
345
+ [surface-frontend](surface-frontend.md). (tcox)
334
346
  - 2026-09-03 — Added the **deep-link-from-a-modal-into-a-nested-table** pattern (pin `grouping`;
335
347
  build the column-filter chip with the `useServerTableUrlState` serializer, keyed by the table-meta
336
348
  **column id**; set `<slug>=<uuid>` to pre-expand the row) and the rule that makes it work:
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-03
9
+ updated: 2026-09-04
10
10
  owners: [jcardinal, apeterson, tcox]
11
11
  files:
12
12
  - toga25-supply/src/layout/RecordApprovalModal/helpers/stackedCurrencyJoiner.ts
@@ -341,6 +341,48 @@ in [surface-layer-schema](../../dbchanges2/features/surface-layer-schema.md):
341
341
  > shows **`leasePrice`** for all clients, whereas the old DEFAULT JSON showed only unit price. Confirm
342
342
  > that is the intended seed shape.
343
343
 
344
+ ### ⚠ VIEW is Surface-driven but EDIT is still JSON — a Core-OFF field disappears from view only
345
+
346
+ A field seeded OFF in Core with **no client `SurfaceOverride`** is invisible in VIEW but still
347
+ rendered in EDIT (edit mode reads `itemRecordEditFields.json`). The symptom is a section header with
348
+ **no fields under it**, and the field appearing the moment the user clicks Edit and vanishing again
349
+ on Cancel — which reads like a data bug, not a config gap. Confirmed 2026-09-04 on the item modal's
350
+ **More Info → "Restrict to Persona"** field for Compass USA + Compass Canada: Core (`2026-07-17d`)
351
+ seeds surface `item-record-more-info` element **57** `isVisible=0` / `isEnabled=0` with
352
+ `config {valueKey: personaItems, computeValueKey: personaItems}`, intending a per-client opt-in, but
353
+ **no Compass override was ever written** (verified on prod **and** client-sandbox: zero
354
+ `SurfaceOverrides` rows for element 57 in either tenant). The data was never missing —
355
+ `apiFields.json`'s COMPASS delta already requests `personaItems.*` (see
356
+ [client-api-fetch-fields](client-api-fetch-fields.md)); only the presentation was. Fix = the missing
357
+ override (`Client_Compass` / `Client_CompassCanada` `2026-09-04a`, see
358
+ [surface-layer-schema](../../dbchanges2/features/surface-layer-schema.md)).
359
+
360
+ **Rule:** when a modal's VIEW moves to Surface and its EDIT stays JSON, every Core-default-OFF
361
+ element needs a per-client `SurfaceOverride` or the field silently drops out of view while staying
362
+ editable. "It shows in Edit mode" is evidence of a **missing override**, not of working config.
363
+
364
+ ### ⚠ A seed's `computeValueKey` must exist in the consuming view's `COMPUTE_VALUE` registry
365
+
366
+ `ItemRecordView`'s `COMPUTE_VALUE` registry is keyed by the exact string in the surface element's
367
+ `config.computeValueKey`. The Core seed and the legacy per-client JSON can use **different key names
368
+ for the same formatter** — and did: the seed says `personaItems`, the JSON said `personaNames`, and
369
+ only `personaNames` was registered. A missing key is **not** a harmless no-op: `hydrateField`
370
+ produces `computeValue` `undefined`, the raw reshaped array `[{uuid, name, _joinUuid}]` reaches
371
+ `BaseDetailField`, and React throws **error #31** ("Objects are not valid as a React child … keys
372
+ {uuid, name, _joinUuid}"). Because this app has **no top-level `ErrorBoundary`** (see
373
+ [frontend standard](../../../standards/frontend.md)), that surfaces as the router's raw
374
+ **"Unexpected Application Error!"** page — the whole modal route dies, not one field.
375
+
376
+ Fix shape: register the formatter under **both** keys, and make it accept both value shapes.
377
+ `formatPersonaItems` (`ItemRecordView.tsx`) handles the view-model's flat reshaped
378
+ `{uuid, name: "<number> - <name>"}` **and** the raw API nested `{persona: {name, number}}`, and is
379
+ registered as `personaNames` (legacy JSON) **and** `personaItems` (Surface seed). Whenever you seed
380
+ a `computeValueKey`, grep the consuming view's registry for that literal string first.
381
+
382
+ > **Deploy order is load-bearing: FE first, or together — never the SQL first.** The override alone
383
+ > makes the field visible and therefore **crashes the modal**; the FE alone changes nothing visible.
384
+ > Same shape as the approvals-gate ship-together dependency.
385
+
344
386
  ## SalesOrders approval-decision modal (approve + deny) — migrated via an OVERLAY adapter seam
345
387
 
346
388
  The SalesOrder approve/deny decision modal's config now sources its display fields from Surface,
@@ -1002,6 +1044,17 @@ claim is about the `navigation-*` **flags** being inert — still true — not a
1002
1044
  unused).
1003
1045
 
1004
1046
  ## Change history
1047
+ - 2026-09-04 — Fixed the item modal's **More Info → "Restrict to Persona"** field missing in VIEW for
1048
+ Compass USA + Canada. Two causes, both now rules here: (1) the item modal's VIEW is Surface-driven
1049
+ while EDIT is still JSON, so a Core-default-OFF element with no client `SurfaceOverride` vanishes
1050
+ from view yet still renders in edit — element **57** on `item-record-more-info` had **zero**
1051
+ override rows in either Compass tenant, on prod and sandbox; (2) the seed's `computeValueKey`
1052
+ (`personaItems`) was missing from `ItemRecordView`'s `COMPUTE_VALUE` registry, which held only the
1053
+ legacy JSON key `personaNames` — so once the field was visible the raw `[{uuid,name,_joinUuid}]`
1054
+ array hit `BaseDetailField` and killed the route with React **error #31**, shown as the router's
1055
+ "Unexpected Application Error!" because the app has no top-level `ErrorBoundary`.
1056
+ `formatPersonaItems` now accepts both value shapes and is registered under both keys. **Deploy the
1057
+ FE first or together — the SQL alone crashes the modal.** (tcox)
1005
1058
  - 2026-09-03 — Recorded that the 2.5 app **still calls `GET /v2/pages/meta`** from five view
1006
1059
  models (SalesOrders — twice per load — VendorItems, Inventory, Bundles, ItemFulfillmentModal)
1007
1060
  even though nothing in `src/` reads a field off the result. Found by the new Cypress
@@ -0,0 +1,92 @@
1
+ ---
2
+ title: Toolbar button chrome (ToolbarButton) — and why blox BaseButton can't be resized from the theme
3
+ framework: "2.0"
4
+ repo: toga25-supply
5
+ project: TOGa 2.5 Supply
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-09-03
10
+ owners: [apeterson]
11
+ files:
12
+ - toga25-supply/src/components/ToolbarButton/ToolbarButton.tsx
13
+ - toga25-supply/src/components/ToolbarButton/ToolbarButton.module.css
14
+ - toga25-supply/src/surface/SurfaceActionBar.tsx
15
+ - toga25-supply/src/themeConfig.json
16
+ related:
17
+ - ../architecture.md
18
+ - surface-frontend.md
19
+ - column-visibility.md
20
+ - ../../toga-blox/features/blox-authoring-defects.md
21
+ ---
22
+
23
+ ## What it is
24
+
25
+ `ToolbarButton` is toga25-supply's **own** chrome for page-toolbar buttons (Refresh, Reset, Columns,
26
+ Group by). It exists because blox's `BaseButton` cannot be made to match the design **from the theme
27
+ layer** — the sizes are literals inside blox's stylesheet, with no token to override.
28
+
29
+ Symptom that started it: one page toolbar rendered three buttons at **three different heights**.
30
+
31
+ ## Why not just theme `BaseButton`
32
+
33
+ Measured against `node_modules/@agilant/toga-blox/dist/components/BaseButton/BaseButton.module.css`:
34
+
35
+ | Fact | Consequence |
36
+ |---|---|
37
+ | `.secondaryBtnAction` hard-codes `height: 26px` | **No theme token exists for it.** A developer trying to resize a toolbar button from `themeConfig.json` will fail and never see why. |
38
+ | `.primaryBtn` declares **no height at all** (computes to ~25px) | Mixed variants in one toolbar never line up. |
39
+ | `font-size` is tokened at 14px; the design says **13.5px** | Tokening it down would restyle every button in the app. |
40
+ | `secondaryAction` is themed blue (border + text `--supplyPrimary-700`), fills navy when active | Wrong signal for a neutral toolbar control. |
41
+
42
+ Retheming those tokens would restyle **every** `secondaryAction` and primary `BaseButton` in the app,
43
+ so the toolbar's chrome went into its own component instead. This is the consumer-side compensation
44
+ that blox defects 4 and 5 call for — see
45
+ [blox authoring defects](../../toga-blox/features/blox-authoring-defects.md). Do **not** patch the
46
+ shared blox stylesheet from this app.
47
+
48
+ ## The design spec (two shapes, both pinned to 27px)
49
+
50
+ Source: the Claude Design project **"TOGa Supply (Offline)"**, `src/supply-table.jsx`.
51
+
52
+ | | pill (Refresh / Reset) | chip (Columns / Group by) |
53
+ |---|---|---|
54
+ | radius | 999 | 6 |
55
+ | height | 27px | 27px |
56
+ | padding | 4px 12px | 0 8px |
57
+ | gap | 8 | 6 |
58
+ | hover | `#F8F8F9` | `#EEF0F6` |
59
+
60
+ Shared: 1px gray-300 border, white ground, gray-800 text, **13.5px / 500**, letter-spacing `.1px`,
61
+ glyph 14px. **Both shapes are pinned to 27px on purpose** so they line up *by construction* rather
62
+ than by luck with whatever font metrics resolve.
63
+
64
+ **The "applied" signal never fills the button.** Instead the glyph moves into a small filled rounded
65
+ square — Group by: 18×18, radius 4, `#3F5DCA`, white glyph.
66
+
67
+ **Buttons the design does not specify keep `BaseButton`:** PRIMARY create actions, and the Approvals
68
+ filter button (SurfaceElement 22, `config.role = filterButton`), whose navy fill is a clearer "on"
69
+ signal than a chip.
70
+
71
+ ## Gotchas
72
+
73
+ - **Two theme tokens referenced in app CSS do not exist.** `--border-strong` and
74
+ `--text-body-disabled` are **not** in `src/themeConfig.json`. CSS using them silently falls through
75
+ to the literal fallback, so it *renders correctly* and the mistake is invisible — while defeating
76
+ theming entirely. The real tokens are **`--gray-300` (#D4D5D8)** and **`--gray-400` (#A7A9AF)**.
77
+ Check any `var(--token, #literal)` against `themeConfig.json` before trusting it.
78
+ - **`--gray-50` is `#FCFCFD`, not `#F8F8F9`.** That value is `--gray-100`. The pill hover is
79
+ `--gray-100`.
80
+ - **A hard-coded size in blox is invisible from the theme layer.** If a size will not budge from
81
+ `themeConfig.json`, read the blox `*.module.css` in `node_modules` before assuming your CSS is
82
+ wrong.
83
+ - **Not built (deliberate):** the design's "modified" Columns state — a teal `#4CBEC5` chip with a
84
+ `Columns (N)` label when the columns differ from default.
85
+
86
+ ## Change history
87
+ - 2026-09-03 — Created. A page toolbar rendered three buttons at three different heights; the cause is
88
+ that blox `.secondaryBtnAction` hard-codes `height: 26px` with **no theme token**, while
89
+ `.primaryBtn` sets no height at all. Rethemeing would hit every button in the app, so the toolbar
90
+ chrome moved into an app-owned `ToolbarButton` (pill + chip, both pinned to 27px). Also recorded
91
+ that `--border-strong` and `--text-body-disabled` are referenced in app CSS but **do not exist** in
92
+ `themeConfig.json` — the literal fallback hides the error. (apeterson)
@@ -223,6 +223,32 @@ is the path the sales-order badge cannot use** — its tokens were never seeded,
223
223
  blox's `useStatusColors` workaround exists. Because these tokens *are* seeded, transfer orders
224
224
  resolve natively with no workaround.
225
225
 
226
+ ### The STATUS badge chain — five links, and the CELL VALUE must be the vocabulary key (2026-09-03)
227
+
228
+ The list column and the record-modal dot are now a real coloured status badge. The chain the platform
229
+ walks, end to end:
230
+
231
+ ```
232
+ RecordFields.type = 'STATUS'
233
+ → Records(recordField.recordId).statusRecordId
234
+ → that record's route
235
+ → useStatusTypeValues fetches /v2/<route>?fields=slug,name,colorHex
236
+ → blox StatusCell keys typeValues[value]
237
+ ```
238
+
239
+ **Consequence: the cell value must be the key `typeValues` is built on.** `StatusCell` does a plain
240
+ map lookup on the value the column returns — so a column that returns a display name against a
241
+ slug-keyed vocabulary renders unstyled, with no error. `Core/2026-09-03a` sets
242
+ `RecordFields.type = 'STATUS'` and points `Records.statusRecordId` at the stage record;
243
+ `Client_Nychh/2026-09-03a` adds the ACL the vocabulary fetch needs.
244
+
245
+ **`useStatusColors` is keyed by route AND by `keyField`, because the two vocabularies bind
246
+ differently.** Sales-order terms carry the status **slug**; transfer-order terms carry the stage
247
+ **NAME** (`TransferOrderStages` has no slug column — see the section above). One route map was not
248
+ enough, so `src/surface/useStatusColors.ts` now keys a vocabulary to `{ route, keyField }` rather
249
+ than to a route alone. Adding a third STATUS vocabulary means answering "what does its
250
+ `VocabularyTerms.value` actually hold?" first.
251
+
226
252
  ## The NYCHH navigation opt-in
227
253
 
228
254
  One `SurfaceOverrides` row, `IS_VISIBLE = '1'`, **client-wide** (`personaId`/`roleId`/`languageId`
@@ -507,6 +533,44 @@ push) — see
507
533
  `contact: { uuid }`, `createdByUser: { uuid }`. Each is omitted entirely when absent rather than sent
508
534
  as `null`: a location can have no contact, and an SSO session can lack a user uuid.
509
535
 
536
+ ### The picker is VIRTUALIZED — spacer rows inside a real `<table>` (2026-09-03)
537
+
538
+ `view/TransferItemPickerTable.tsx` renders through **`@tanstack/react-virtual`**. The rendering
539
+ trick that matters: the virtualizer's offsets are applied as **two spacer `<tr>` rows inside the
540
+ real `<table>`**, above and below the visible window — *not* by wrapping rows in an absolutely
541
+ positioned `<div>`. That keeps `table-layout: fixed`, the `<colgroup>` widths and the sticky
542
+ `<thead>` all working, which the div-wrapper approach silently breaks.
543
+
544
+ Measured on a 1,232-row list: median keystroke latency **248 ms → 24 ms**, DOM nodes
545
+ **20,464 → 1,254**, **13** rows mounted at a time.
546
+
547
+ > ⚠ **`autoFocus` cannot be used on a row inside a virtualized list.** A row unmounts and remounts
548
+ > as you scroll, and each remount re-fires `autoFocus` — so the qty input steals focus back from
549
+ > whatever the user is actually typing in. Replaced with an explicit `ref` + `useEffect` that focuses
550
+ > **once**, on the row that was just checked.
551
+
552
+ ### A new transfer POSTs an initial stage — and the stage is sent as `{ id }`, NOT `{ uuid }`
553
+
554
+ `hooks/useCreateTransferOrder.ts` now sends an opening stage. **`TransferOrderStages` has no `uuid`
555
+ identifier**, so the FK convention used by every other write in this app does not apply here:
556
+
557
+ ```
558
+ // WRONG — 400 EV-12, "searchableIdentifierFields": ["id","c_netsuiteInternalTransferOrderStatus"]
559
+ transferOrderStage: { uuid: "..." }
560
+
561
+ // CORRECT — look the stage up by name at request time, send its id
562
+ transferOrderStage: { id: 7 } // the row whose name is "Pending"
563
+ ```
564
+
565
+ The stage is resolved **by name (`"Pending"`) at request time** rather than by a hardcoded id,
566
+ because stage ids differ per tenant database. Read the `searchableIdentifierFields` list in the
567
+ EV-12 body — it names the identifiers the record actually has. General rule and the engine detail:
568
+ [nested-relationship-writes](../../api2/features/nested-relationship-writes.md).
569
+
570
+ > This closes open question 2 below ("nobody knows which stage NYCHH treats as the opening stage")
571
+ > **for the front end only** — the FE now picks `Pending`. Whether that is the tenant's intended
572
+ > opening stage is still Jeff's call.
573
+
510
574
  ### `TransferOrders.contactId` / `createdByUserId` / `description` — new schema, model and metadata
511
575
 
512
576
  `Client/2026-09-01c` adds the three columns, `_underscore/Model/Client/TransferOrder.php` declares
@@ -628,6 +692,13 @@ be exercised while the `_qty*` blocker above is unresolved.
628
692
  first pass had been built from an image and diverged (one shared select vs. two, "All POs" as a row
629
693
  vs. a clear button).
630
694
 
695
+ > 🚨 **Read the design SOURCE, not an extracted summary — one of the summaries is WRONG.**
696
+ > `ground-truth-transfer-orders-v2.md` claims transfer fulfilment does not exist in the data model.
697
+ > It does. `demo/transfer-modal.jsx` defines the column outright —
698
+ > `{ key: "ful", label: "Qty Fulfilled", width: "128px", align: "right" }`, clickable per line when
699
+ > the value is > 0 and clickable in the header when any line has a value — and the data model
700
+ > supports it (see `_qtyFulfilled` below). An extraction is a lossy copy; the `.jsx` is the source.
701
+
631
702
  ### Line-item fetch is off behind a FLAG, not commented out
632
703
 
633
704
  `TRANSFER_ORDER_LINES_ENABLED = false` until `Client/2026-08-28b` is applied everywhere. A flag,
@@ -661,6 +732,61 @@ visible in Core (neutral default; other tenants populate `TransferOrders.purchas
661
732
  **The header's PO value is now a link into Inventory** (above); for NYCHH it is the same null, so
662
733
  nothing renders there for this tenant yet.
663
734
 
735
+ ## Entry point #2 — the Inventory page's "Create Transfer Order" button (2026-09-03)
736
+
737
+ The create modal is now reachable from **Inventory** as well as from the Transfer Orders list. It
738
+ follows the house pattern exactly: **`Core/2026-09-03b` seeds the header action button
739
+ `isVisible = 0`** (the true neutral default), and **`Client_Nychh/2026-09-03b` opts the tenant in**
740
+ with one `SurfaceOverrides` row. FE side: `src/pages/Inventory/Inventory.tsx` +
741
+ `viewModel/useInventoryPageViewModel.tsx`.
742
+
743
+ > **FIXED a latent bug in the same change: the Inventory header-button builder ignored
744
+ > `el.isVisible`.** Hidden elements **do** arrive in the resolved bundle — the resolver ships the
745
+ > whole tree and marks state, it does not prune — so any builder that maps elements to buttons
746
+ > without filtering on `isVisible` renders every element the Core seed deliberately turned off. This
747
+ > had no visible symptom before only because no hidden element existed on that bar yet; the moment
748
+ > `2026-09-03b` seeded one, it would have shown for **every** tenant. Check this filter on every
749
+ > surface consumer that builds its own controls: [surface-frontend](./surface-frontend.md).
750
+
751
+ ## `_qtyFulfilled` on transfer order items — and the drill-down modal that does NOT work yet
752
+
753
+ **Built:** `_underscore/Model/Client/TransferOrderItem.php` (branch
754
+ `features-talos-transferorders`) gains a `FIELD_SQL` **`_qtyFulfilled`** that sums
755
+ `ItemFulfillmentItems.quantity` for fulfilments whose status slug is `shipped`, walking
756
+ `ItemFulfillments → ItemFulfillmentStages → ItemFulfillmentStatuses`.
757
+
758
+ - It **mirrors `_Model_Client_PurchaseOrderItem::_qtyFulfilled`**, with one simplification: it needs
759
+ **no bridge table**, because `ItemFulfillmentItems.transferOrderItemId` points straight at the
760
+ transfer line.
761
+ - **`COALESCE`, not `IFNULL`** — an unfulfilled line must read `0`, not `NULL`, or the FE renders a
762
+ blank cell where the design wants a zero.
763
+
764
+ **⚠ NOT built, and the column cannot ship without it:** the front-end column render, and the
765
+ `Client_Nychh` TableViews the drill-down modal needs. The **Core seed for the Qty Fulfilled column is
766
+ stashed in `dbchanges2` (`stash@{0}`) — its rows were already run on sandbox-client Core**, so that
767
+ environment is ahead of the repo. Treat neither as shipped.
768
+
769
+ ### The shared "Qty Fulfilled" drill-down modal is entirely TABLEVIEW-driven — no TableView, no modal
770
+
771
+ `src/layout/ItemFulfillmentModal/useItemFulfillmentModalViewModel.tsx`. **The `slug` prop passed to
772
+ `ItemFulfillmentModal` IS a TableView slug**, not a component variant name:
773
+ `useTablePageMeta({ slug })` supplies the columns and `useTableData({ slug, tableViewMeta })`
774
+ supplies the rows. **If no TableView row exists for that slug, the modal opens empty** — no error,
775
+ no request failure.
776
+
777
+ **Two spots are hardcoded to sales orders and must be generalised before a transfer-order slug can
778
+ work:**
779
+
780
+ 1. **line ~27** — the label fetch always requests page meta `"item-fulfillments-for-sales-orders"`,
781
+ whatever slug was passed.
782
+ 2. **lines ~55–60** — the record filter falls back to **`"SalesOrders.uuid"`** for any unrecognised
783
+ slug. So a transfer-order slug today filters by the wrong column and returns **nothing** — an
784
+ empty modal that looks like missing data.
785
+
786
+ **Measured in `Client_Nychh`: only TableViews 12 (`item-fulfillments-for-sales-orders`) and 13
787
+ (`item-fulfillments-for-sales-order-items`) exist, both on `recordId` 29.** Neither transfer-order
788
+ equivalent exists anywhere. Two new `Client_Nychh` TableViews are required.
789
+
664
790
  ## Rollout status & open items
665
791
 
666
792
  - 🚨 **CORRECTED 2026-09-03 — the Core seeds ARE on production.** The line below said "nothing has
@@ -766,6 +892,28 @@ deliberate, separate exception — see [surface-frontend](./surface-frontend.md)
766
892
 
767
893
  - **⚠ An empty dropdown with no request in the network tab is a numeric `where` value or a
768
894
  non-zero `staleTime`** — see the section above. Neither logs anything.
895
+ - **⚠ An ungranted `uuid` field 403s the WHOLE request** — `uuid` is V2's `IDENTIFIER_FIELD` and is
896
+ force-added to every record read, so a record whose role lacks an `AclFieldPermissions` grant on
897
+ its `uuid` RecordField returns **403 `EZ-2`** listing `"fields": ["uuid"]`. Ordinary ungranted
898
+ fields are silently dropped; `uuid` is not. Hit on `/transfer-order-statuses`. **Always grant the
899
+ `uuid` RecordField alongside the fields you actually read.** See
900
+ [acl-permission-chain](../../_underscore/features/acl-permission-chain.md).
901
+ - **⚠ Not every FK resolves by `uuid`.** `TransferOrderStages` has no `uuid` identifier — sending
902
+ `{ uuid }` returns **400 `EV-12`**. Read `searchableIdentifierFields` in the error body; it names
903
+ what the record actually accepts (here `id`).
904
+ - **⚠ `autoFocus` is unusable inside a virtualized row** — the row remounts as you scroll and
905
+ re-fires it, stealing focus mid-typing. Use an explicit `ref` + `useEffect`.
906
+ - **⚠ A virtualizer must place its offsets as spacer `<tr>`s INSIDE the `<table>`** — an absolutely
907
+ positioned wrapper `<div>` silently breaks `table-layout: fixed`, `<colgroup>` widths and the
908
+ sticky `<thead>`.
909
+ - **⚠ A STATUS cell renders unstyled if the column value is not the vocabulary KEY.** blox's
910
+ `StatusCell` is a plain `typeValues[value]` lookup — a display name against a slug-keyed
911
+ vocabulary just misses, with no error.
912
+ - **⚠ Hidden surface elements DO arrive in the resolved bundle** — any hand-rolled button/control
913
+ builder must filter on `el.isVisible` itself. The Inventory header builder did not, and would have
914
+ shown a deliberately-hidden Core element to every tenant.
915
+ - **⚠ An `ItemFulfillmentModal` `slug` is a TABLEVIEW slug.** No TableView row → the modal opens
916
+ empty, silently. And its label fetch + record filter are still hardcoded to sales orders.
769
917
  - **⚠ A bordered scroll box must be `flex: 0 1 auto`, never `flex: 1`.** `flex: 1` on the element
770
918
  that carries the border stretches the *visible* table to the bottom of the modal, so one row draws
771
919
  a box full of white. Put `flex: 1` on the wrapper that draws nothing.
@@ -815,6 +963,34 @@ deliberate, separate exception — see [surface-frontend](./surface-frontend.md)
815
963
  earlier migration without names, so a name-based lookup on them finds nothing.
816
964
 
817
965
  ## Change history
966
+ - 2026-09-04 — **Virtualized the item picker, made the create POST set an opening stage, and lit up
967
+ the status badge.** (1) `TransferItemPickerTable` now renders through `@tanstack/react-virtual`
968
+ with the offsets as **spacer `<tr>`s inside the real `<table>`**, so `table-layout: fixed`, the
969
+ `<colgroup>` and the sticky `<thead>` all survive — measured on 1,232 rows: keystroke latency
970
+ **248 ms → 24 ms**, DOM nodes **20,464 → 1,254**, 13 rows mounted; `autoFocus` on the qty input had
971
+ to become an explicit `ref` + `useEffect` because a virtualized row remounts and re-steals focus.
972
+ (2) A new transfer now POSTs an opening stage, and ⚠ **`TransferOrderStages` is sent as `{ id }`,
973
+ not `{ uuid }`** — it has no `uuid` identifier, so `{ uuid }` returns 400 `EV-12` naming
974
+ `searchableIdentifierFields: ["id","c_netsuiteInternalTransferOrderStatus"]`; the stage is resolved
975
+ **by name (`"Pending"`) at request time** because ids differ per tenant. (3) Coloured status badge
976
+ on the list + matching dot in the record modal (`Core/2026-09-03a`, `Client_Nychh/2026-09-03a`):
977
+ documented the five-link STATUS chain and the rule that **the cell value must be the vocabulary
978
+ key**, and generalised `useStatusColors` to key on **`{ route, keyField }`** because sales-order
979
+ terms carry a slug while transfer-order terms carry the stage NAME. (4) Added the **Inventory
980
+ "Create Transfer Order"** entry point (`Core/2026-09-03b` seeds it `isVisible = 0`,
981
+ `Client_Nychh/2026-09-03b` opts in) and **fixed a latent bug**: the Inventory header-button builder
982
+ ignored `el.isVisible`, and hidden elements **do** arrive in the resolved bundle. (5) Built
983
+ **`_qtyFulfilled`** on `_Model_Client_TransferOrderItem` (`FIELD_SQL` summing shipped
984
+ `ItemFulfillmentItems`; no bridge table needed because `transferOrderItemId` is direct;
985
+ `COALESCE` so an unfulfilled line reads 0). ⚠ **Discovered the shared Qty-Fulfilled drill-down
986
+ modal is entirely TableView-driven** — the `slug` prop IS a TableView slug, and two spots are
987
+ hardcoded to sales orders (label fetch `item-fulfillments-for-sales-orders`, filter fallback
988
+ `SalesOrders.uuid`), while `Client_Nychh` has only TableViews 12/13 (both `recordId` 29). The FE
989
+ column and those TableViews are **NOT done**, and the Core column seed is stashed
990
+ (`dbchanges2 stash@{0}`) though already run on sandbox-client Core. Also ⚠ recorded that an
991
+ **ungranted `uuid` 403s the whole request**, and that
992
+ `ground-truth-transfer-orders-v2.md` **wrongly** says transfer fulfilment does not exist — read
993
+ `demo/transfer-modal.jsx`, which defines the `ful` / "Qty Fulfilled" column. (apeterson)
818
994
  - 2026-09-03 — **CORRECTED the production run state:** the Core transfer-order surfaces (all six
819
995
  record/list surfaces **plus `transfer-order-create-items`**) and `talos-assistant` **are on
820
996
  production** — verified live on `reader1.core.database.togahub.com` (`Surfaces` = 53). The
@@ -31,7 +31,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
31
31
  - **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
32
32
  - **ai-bdr** (AI-BDR) — 13 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
33
33
  - **toga2-commerce** (TOGa Commerce) — 21 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
34
- - **toga25-supply** (TOGa 2.5 Supply) — 19 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
34
+ - **toga25-supply** (TOGa 2.5 Supply) — 20 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
35
35
  - **toga-blox** (TOGa Blox) — 14 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
36
36
  - **bdr** (BDR) — 0 doc(s) → [2.0/apps/bdr/INDEX.md](2.0/apps/bdr/INDEX.md)
37
37
 
@@ -98,6 +98,14 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
98
98
  see [Config-Driven Expedited Shipping Gating](../../2.0/apps/toga2-commerce/features/expedited-shipping-gating.md).
99
99
 
100
100
  ## Notes
101
+ - **Item modal "Restrict to Persona" needed a Surface opt-in that was never written (2026-09-04).**
102
+ Same gap as Compass USA: Core seeds the More-Info `restrictToPersona` field **OFF** (surface 19 /
103
+ element 57) and Canada had **no** override, so the item modal's **More Info** section rendered
104
+ empty in view (the field appeared only in Edit mode).
105
+ `dbchanges2/Client_CompassCanada/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql` adds the
106
+ client-wide `IS_VISIBLE`+`IS_ENABLED` rows — **not yet run in any environment**.
107
+ ⚠ **Deploy the toga25-supply FE first or together: the SQL alone crashes the modal** (React error
108
+ #31). See [Surface Frontend](../../2.0/apps/toga25-supply/features/surface-frontend.md).
101
109
  - **Surface layer (beta):** like Quad, `Client_CompassCanada` had **no** `AclRecordScripts` dispatch
102
110
  grant for the surfaces `meta-group` script (only `meta`, to client role 1) → `GET /v2/surfaces/meta-group`
103
111
  403'd EZ-1 while `meta` worked. Fixed 2026-07-01 via
@@ -205,6 +205,16 @@ separate, related client (see its own profile).
205
205
  the ⚠ rule that **deactivation is raw bulk SQL, so no model hook ever fires on it**.
206
206
 
207
207
  ## Notes
208
+ - **Item modal "Restrict to Persona" needed a Surface opt-in that was never written (2026-09-04).**
209
+ Core seeds the item modal's More-Info `restrictToPersona` field **OFF** for every client
210
+ (surface 19 / element 57), expecting a per-client override — Compass USA had **none**, so the
211
+ modal's **More Info** section rendered **empty in view** while the field still showed in Edit
212
+ mode. Fixed by `dbchanges2/Client_Compass/2026-09-04a - ItemRecordRestrictToPersonaVisible.sql`
213
+ (client-wide `IS_VISIBLE`+`IS_ENABLED`). **Run on client-sandbox only — prod pending.**
214
+ ⚠ **Deploy the toga25-supply FE first or together: the SQL alone crashes the modal** (React error
215
+ #31, whole modal route). See
216
+ [Surface Frontend](../../2.0/apps/toga25-supply/features/surface-frontend.md) and
217
+ [Surface Layer Schema](../../2.0/apps/dbchanges2/features/surface-layer-schema.md).
208
218
  - **Order status is shipped-only (2026-06-30).** Compass imports all IF stages
209
219
  (picked/packed/shipped) but its order `_status` (`_underscore/Model/Compass/SalesOrder.php`,
210
220
  shared by Compass USA + Canada) counts **shipped only** — picked/packed never advance a Compass
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.779",
3
+ "version": "1.0.781",
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",