toga-ai 1.0.643 → 1.0.645

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.
@@ -16,7 +16,7 @@
16
16
  | [1.0 MVC Page Pattern & New-App Skeleton](features/mvc-page-pattern-and-app-skeleton.md) | This is the **reusable recipe for standing up a new 1.0 (`App_`) application** and for adding pages to one — the folder-based MVC routing, the page lifecycle, t | library/app/framework.php, library/app/frameworkindex.php, library/app/mvc.php, library/app/database.php, library/app/model.php, library/app/config.php |
17
17
  | [isFulfillable from NetSuite during Item Sync (Phase 1)](features/netsuite-item-isfulfillable-sync.md) | This is the **1.0 (Phase 1)** half of the `isFulfillable` feature: reading the NetSuite `isfulfillable` flag during item sync and stamping it onto the **Agilant | library/app/netsuite.php, library/app/api/toga2.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/backfill_isfulfillable_jul5.php |
18
18
  | [NetSuite SuiteQL/REST API Reference](features/netsuite-suiteql-api-reference.md) | General working reference for the Agilant NetSuite integration: how to authenticate, how SuiteQL behaves, and the confirmed schema of the tables/columns/codes w | library/app/api/netsuite/rest.php, library/ssl/netsuite_ec_key.pem, test/@dave/Junk Drawer/nsq.php |
19
- | [NetSuite SuiteQL/REST Shim — Field Semantics](features/netsuite-suiteql-rest-shim.md) | `App_Api_Netsuite_Rest` is the REST/SuiteQL replacement for the deprecated NetSuite SOAP toolkit. | library/app/api/netsuite/rest.php |
19
+ | [NetSuite SuiteQL/REST Shim — Field Semantics](features/netsuite-suiteql-rest-shim.md) | `App_Api_Netsuite_Rest` is the REST/SuiteQL replacement for the deprecated NetSuite SOAP toolkit. | library/app/api/netsuite/rest.php, library/app/netsuite.php |
20
20
  | [NetSuite Sync Alert Monitor (App_SystemMonitor_NetSuiteIntegration)](features/netsuite-sync-alert-monitor.md) | `App_SystemMonitor_NetSuiteIntegration` (`library/app/systemmonitor/netsuiteintegration.php`, title **"NetSuite Sync Alert"**) is a 1.0 system monitor that watc | library/app/systemmonitor/netsuiteintegration.php, worker/crons/infrastructure/system_monitors.php |
21
21
  | [Startech PC Matic B2B Sync (library)](features/startech-pcmaticb2b-sync.md) | `library/app/api/toga2.php` handles bidirectional ticket sync for PC Matic B2B between TOGaDesk 1.0 and TOGA 2.0. | library/app/api/toga2.php, library/app/api/startechticket.php, worker/crons/toga2/startech/common_import_supporting_records.php |
22
22
  | [App_Api_Toga2 — TOGa2 API Client & 1.0↔2.0 Sync Bridge](features/toga2-api-client-and-bridge.md) | `App_Api_Toga2` (`library/app/api/toga2.php`, ~8400 lines) is the **1.0-side client for the TOGa 2 (`_underscore`/api2) public API** *and* the home of the cross | library/app/api/toga2.php, worker/crons/toga2/aig/sync_togasupply_aig.php, worker/crons/toga2/wje/sync_togasupply_wje.php, test/@Mark/AIG/test_multi_email.php |
@@ -6,14 +6,16 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-11
10
- owners: [dfranks]
9
+ updated: 2026-08-25
10
+ owners: [dfranks, jcardinal]
11
11
  files:
12
12
  - library/app/api/netsuite/rest.php
13
+ - library/app/netsuite.php
13
14
  related:
14
15
  - netsuite-suiteql-api-reference.md
15
16
  - ../architecture.md
16
17
  - ../../worker/features/forecast2-netsuite-reconciliation.md
18
+ - ../../../2.0/apps/_underscore/features/netsuite-rest-client.md
17
19
  ---
18
20
 
19
21
  ## Summary
@@ -90,6 +92,35 @@ Forecast2 tables already store.
90
92
  - **TO_DATE rejects impossible dates** (e.g. `2026-06-31`) with an opaque HTTP 400 — validate
91
93
  calendar dates before building SuiteQL.
92
94
 
95
+ ## Transaction status sourcing (1.0) — and the 2026.2 REST `status.id` change
96
+
97
+ 1.0 reaches NetSuite over **two transports, and neither reads status off a REST record GET
98
+ body**:
99
+
100
+ - **REST adapter (`App_Api_Netsuite_Rest`, `rest.php`).** It *does* issue `/record/v1/...`
101
+ GETs, but it sources `status` from the **SuiteQL header row** (`$h->status`, from the
102
+ `SELECT ... status ... FROM ...` at ~L381/574/754 — already a letter) and maps that letter
103
+ to the SOAP-era text label via an internal **`$statusMap`** (~L471-472, L625-626,
104
+ L853-854/863). The REST GET body (`$rec`) is only read for shipMethod / opportunity / terms /
105
+ createdFrom / country — **never status**. Downstream forecast2 crons
106
+ (`worker/crons/toga2/forecast2/*`) compare `$nsRecord->status` to text, but that text is
107
+ *adapter-produced* (from the SuiteQL letter via `$statusMap`), not from the changing REST
108
+ field.
109
+ - **SOAP (`App_NetSuite`, `library/app/netsuite.php`).** Always uses `new NetSuiteService()`
110
+ (SuiteTalk toolkit) and reads transaction status off the SOAP record as text (e.g.
111
+ `$items->status != 'Billed'/'Cancelled'/'Closed'` ~L1151/L1434; `$nsInvoice->status ==
112
+ 'Paid In Full'` ~L2546). SOAP is a different API and is unaffected. (The many
113
+ `->status->isSuccess` / `statusDetail` reads elsewhere are the SOAP **response envelope**,
114
+ not the record's business status.)
115
+
116
+ > **NetSuite 2026.2 impact: none for 1.0.** 2026.2 standardizes the `status.id` returned by
117
+ > **REST record GETs** for transactions to single-letter codes across all types (some types
118
+ > return it as text like `"Open"` today); `status.refName` and SuiteQL are unchanged, and
119
+ > SOAP is a separate API. Since no 1.0 code reads `status.id` off a REST GET body, no patch is
120
+ > needed. NetSuite SuiteAnswers ID **89313**. The 2.0 side of the same conclusion (shared REST
121
+ > client + worker2 consumers) is in
122
+ > [`netsuite-rest-client.md`](../../../2.0/apps/_underscore/features/netsuite-rest-client.md).
123
+
93
124
  ## Data model
94
125
 
95
126
  Reads NetSuite `transaction`, `transactionline`, `previoustransactionlinelink` via SuiteQL and
@@ -108,6 +139,12 @@ None — uniform across clients (NetSuite is a single shared account).
108
139
 
109
140
  ## Change history
110
141
 
142
+ - 2026-08-25 — Documented **1.0 transaction-status sourcing across both transports** and the
143
+ conclusion that the **NetSuite 2026.2 REST `status.id` text→letter standardization does not
144
+ affect 1.0** (SuiteAnswers 89313). `App_Api_Netsuite_Rest` derives status from the SuiteQL
145
+ letter and maps it via an internal `$statusMap` — never from the REST GET body; forecast2
146
+ crons see that adapter-produced text, not the changing field; `library/app/netsuite.php` is
147
+ pure SOAP (a different API). Discovery/impact-analysis only — no code changed. (jcardinal)
111
148
  - 2026-06-11 — Documented bulk SuiteQL field semantics (tl.id==line, ShipItem NULL cost, iscogs
112
149
  COGS filter, createdFrom non-determinism, foreignamountunpaid, sign conventions) surfaced while
113
150
  moving the Forecast2 trueup tools off per-id REST GETs. (dfranks)
@@ -19,7 +19,7 @@
19
19
  | [Core.Domains — the app host registry (and why you cannot derive a host)](features/core-domains-app-host-registry.md) | `Core.Domains` is the **authoritative registry of which host serves which client's app in which environment**. | _underscore/Model/Core/Domain.php, api2/Component/Api/CrossClient/CrossClient.php, api2/Component/Api/V2/V2.php, worker/config.beta.ini |
20
20
  | [Re-pointing a DB alias mid-request (_Database::register park/restore)](features/database-alias-repointing.md) | `_Database` keys **all live per-database runtime state by the connection ALIAS** (`Client` / `_underscore::DB_CLIENT`, `ClientLogs`, `Archive`), **not** by the | _underscore/Database.php, _underscore/Query.php, api2/Component/Api/V2/V2.php, api2/Component/Api/CrossClient/CrossClient.php |
21
21
  | [2.0 Email Send Pipeline (queue + Send worker)](features/email-send-pipeline.md) | In 2.0, `_Email::send()` **does not transmit** — it queues the message. | _underscore/Email.php, _underscore/String.php, worker2/Worker/Infrastructure/Email/Send.php, _underscore/Model/Client/Logs/Email.php, _underscore/Model/Client/Logs/EmailAttachment.php |
22
- | [Client Email Template Sending](features/email-template-sending.md) | `_Model_Client_EmailTemplate` sends a stored, client-defined email template by UUID. | _underscore/Model/Client/EmailTemplate.php, _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php, _underscore/Email.php |
22
+ | [Client Email Template Sending](features/email-template-sending.md) | `_Model_Client_EmailTemplate` sends a stored, client-defined email template by UUID. | _underscore/Model/Client/EmailTemplate.php, _underscore/Model/Quad/ApprovalDecision.php, _underscore/Model/Quad/SalesOrder.php, _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php, _underscore/Email.php |
23
23
  | [Error Reporting — Issue/Event Capture, Fingerprinting & Aggregation](features/error-reporting-issue-event.md) | Platform-wide error reporting for TOGA 2.0, built on an **Issue / Event** aggregation model in the **shared Core Logs DB**. | _underscore/Error.php, _underscore/Database.php, _underscore/Exception/Business.php, api2/Controller/Index.php, worker2/Controller/Index.php, _underscore/Model/Core/Logs/Issue.php, _underscore/Model/Core/Logs/Event.php, _underscore/Model/Core/Logs/IssueFingerprint.php, _underscore/Model/Core/Logs/IssueClickupTask.php, _underscore/Model/Core/Logs/IssueEmailAddress.php, _underscore/Model/Core/Logs/IssueAreaOwner.php, worker2/Worker/Infrastructure/Errors.php, tools/mvc/errors/issue/get.php, tools/mvc/errors/post.php, dbchanges2/Logs/2026-07-30a - Error reporting Issues and Events.sql, dbchanges2/Logs/2026-08-01a - Rename tables to singular.sql, dbchanges2/Logs_Client/2026-08-01a - Drop Error table.sql, dbchanges2/Core/2026-07-30a - Error escalation cron job.sql |
24
24
  | [Record-Changed Event Publishing (_Event::publish to SQS)](features/event-publish-sqs.md) | `_Event::publish()` (in `_underscore/Event.php`) is the PHP side of the real-time event pipeline. | _underscore/Event.php |
25
25
  | [FIELD_STORAGE fields — per-row lazy hydration and the platform-wide missing-column 500](features/field-storage-row-hydration.md) | `FIELD_STORAGE` is the 2.0 field type for blob-backed columns (S3 or local folder). | _underscore/Model.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/Invoice.php, dbchanges2/Core/HISTORIC/2024/2024-11b - item-fulfillments.sql |
@@ -30,15 +30,16 @@
30
30
  | [_Model magic-field access (__get without __isset)](features/model-magic-field-access.md) | `_Model` exposes DB columns as "magic" properties via `__get()`, but it defines **no** `__isset()`. | _underscore/Model/Core/Model.php, _underscore/Model.php, _underscore/Model/Rate/Subscription.php |
31
31
  | [_Model::save() parent FK cascade — stored-SQL-field recompute deadlocks](features/model-save-parent-cascade-stored-field-deadlock.md) | `_Model::save()` runs a **generic parent foreign-key cascade**: inserting (or saving) a child row that carries an FK to a parent causes `_Model` to **re-load an | _underscore/Model.php, _underscore/Model/Client/PurchaseOrder.php, _underscore/Model/Client/AdvanceShippingNotice.php |
32
32
  | [_Model::save() vs raw _Query — no atomic conditional update](features/model-save-vs-query-atomic-update.md) | `_Model::save()` is a plain load-then-write ORM primitive and **cannot express an atomic conditional update** (an optimistic-concurrency / row-claim guard such | _underscore/Model.php, _underscore/Query.php, _underscore/Model/Rate/Subscription.php |
33
- | [NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL](features/netsuite-rest-client.md) | `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive every worker2/api2 NetSuite caller uses for record GETs, Suit | _underscore/Component/Api/Netsuite/Netsuite.php |
33
+ | [NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL](features/netsuite-rest-client.md) | `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive every worker2/api2 NetSuite caller uses for record GETs, Suit | _underscore/Component/Api/Netsuite/Netsuite.php, _underscore/Trait/Netsuite/SalesOrder.php, worker2/Worker/Netsuite/SalesOrder.php, worker2/Component/Forecast/SaleImport/SaleImport.php, worker2/Worker/Netsuite/Opportunity.php |
34
34
  | [NetSuite Sales Order sync — ship-to address, phone, and PO reference sourcing](features/netsuite-salesorder-address-phone-sync.md) | `_Trait_Netsuite_SalesOrder` is the **shared** sales-order importer composed into **22 client models** (every client on the dbchanges2 `netsuite` module). | _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Model.php, dbchanges2/_modules/netsuite/2026-08-10a - AddressPhoneNumberApiRoleAcl.sql |
35
35
  | [Legacy page meta (Page::meta) & context-scoped ClientRecordFieldSettings](features/page-meta-context-field-settings.md) | `_Model_Core_Page::meta()` is the **legacy** page-meta resolver behind `GET /pages/meta?slug=<slug>` — still the live path for `toga2-supply` and other pre-Surf | _underscore/Model/Core/Page.php, _underscore/Model/Client/TableView.php, toga2-supply/src/components/ui/Tables/PrimaryTable/PrimaryTable.tsx |
36
36
  | [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/Model.php, _underscore/Query.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php, api2/Controller/Index.php |
37
37
  | [Persona Name Translation (PersonaTranslations sidecar)](features/persona-name-translation.md) | Serves Persona **names** in multiple languages by adding a per-language **sidecar** table `PersonaTranslations`, reusing the platform's existing metadata-driven | _underscore/Model/Client/PersonaTranslation.php, dbchanges2/Client/2026-07-22b - PersonaTranslations.sql, dbchanges2/Core/2026-07-22a - PersonaTranslationsRecord.sql, dbchanges2/Client/2026-07-22c - PersonaTranslationsAcl.sql, dbchanges2/Client_CompassCanada/2026-07-22 - PersonaTranslationsFrench.sql, toga2-commerce/src/pages/Account/view/MySettingsView.tsx |
38
38
  | [Record Change Audit Log (Logs_<Client>.Record / RecordField) — reading a field's history](features/record-change-audit-log.md) | Every 2.0 client schema has a sibling **logs** schema `Logs_<Tenant>` (e.g. | _underscore/Model/Client/Logs/Record.php, _underscore/Model/Client/Logs/RecordField.php, _underscore/Model/Client/Logs/CustomRecordField.php |
39
39
  | [Recursive Item Fulfillments (upstream mirroring)](features/recursive-item-fulfillments.md) | In a multi-tier supply chain a sales order (SO) spawns a purchase order (PO) that becomes another SO downstream, and so on. | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillmentItem.php, _underscore/Model/Client/ItemFulfillmentItemUnit.php, _underscore/Model/Client/ItemFulfillmentPackage.php, _underscore/Model/Compass/AdvanceShippingNotice.php, dbchanges2/Core/2026-02-13 - 75601 - RecursiveItemFulfillmentCreation.sql, dbchanges2/Core/2026-06-04 - RecursiveItemFulfillmentPut.sql, dbchanges2/Client_Compass/2026-07-02a - FixSA133377TrackingSerialAndDuplicateIF.sql, dbchanges2/Client_Compass/2026-08-18 - FixSA135471HeroItemHalfQuantityFulfillment.sql |
40
+ | [Per-client sales-order status filter (Surface FILTER_SET → table meta `filterOptions`)](features/sales-order-status-filter-surface.md) | The status-filter dropdown on the sales-orders table is **per client**, driven by a Surface `FILTER_SET` rather than by the raw contents of the client's `SalesO | _underscore/Model/Client/TableView.php, _underscore/Model/Core/Surface.php, _underscore/Model/Client/SalesOrder.php, _underscore/Model/Quad/SalesOrder.php, _underscore/Model/Prudential/SalesOrder.php, toga-blox/src/components/Table/hooks/useFetchTablePageMeta.ts, toga-blox/src/api/types.ts, 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 |
40
41
  | [_String helpers — ASCII-safe HTML entity encoding (and the parseBetween trap)](features/string-html-entity-helpers.md) | `_String` is the 2.0 framework's static string utility class. | _underscore/String.php |
41
- | [Surface Resolver (_Model_Core_Surface::resolve — replaces Page::meta)](features/surface-resolver.md) | The runtime for the platform-wide **Surface** UI presentation layer: 9 `_underscore` models plus a cached resolver, `_Model_Core_Surface::resolve(&$api, string | dbchanges2/Core/2026-08-21 - SalesOrderDecisionSurfacesReseed.sql, dbchanges2/Core/2026-08-24 - RestoreApproveDenyRowActionsVisibility.sql, dbchanges2/Client_Quad/2026-08-24 - ProdPortApprovePoNumberEnabledRule.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, toga25-supply/src/App.tsx, toga25-supply/src/surface/useFetchSurfaceMeta.ts, toga25-supply/src/contexts/AuthContext.tsx, dbchanges2/Core/2026-07-21a - SalesOrderDecisionSummarySurfaceSeed.sql, dbchanges2/Core/2026-07-21b - SalesOrderDecisionActionSurfaceSeed.sql, dbchanges2/Core/2026-08-21a - SalesOrderDecisionSurfacesReseed.sql, dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_Quad/2026-07-21c - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_Compass/2026-08-04a - ApprovalDetailsAssignedManagerPreferredStage.sql, _underscore/Model/Core/Surface.php, _underscore/Model/Client/AclRecordScript.php, _underscore/Model/Core/RecordScript.php, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Quad/2026-07-01a - GrantSurfacesMetaGroupScriptAcl.sql, dbchanges2/Client_CompassCanada/2026-07-01a - GrantSurfacesMetaGroupScriptAcl.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Client_Compass/2026-07-15f - SalesOrderRecordActionsRemoveDeadConfigRuleOverrides.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, _underscore/Model/Core/SurfaceElement.php, _underscore/Model/Core/Action.php, _underscore/Model/Core/Vocabulary.php, _underscore/Model/Core/VocabularyTerm.php, _underscore/Model/Core/Message.php, _underscore/Model/Client/SurfaceOverride.php, _underscore/Model/Client/MessageTranslation.php, _underscore/Model/Client/ThemeToken.php, _underscore/Model/Core/Page.php, api2/Component/Api/V2/V2.php |
42
+ | [Surface Resolver (_Model_Core_Surface::resolve — replaces Page::meta)](features/surface-resolver.md) | The runtime for the platform-wide **Surface** UI presentation layer: 9 `_underscore` models plus a cached resolver, `_Model_Core_Surface::resolve(&$api, string | _underscore/Model/Client/Language.php, dbchanges2/Core/2026-08-21 - SalesOrderDecisionSurfacesReseed.sql, dbchanges2/Core/2026-08-24 - RestoreApproveDenyRowActionsVisibility.sql, dbchanges2/Client_Quad/2026-08-24 - ProdPortApprovePoNumberEnabledRule.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, toga25-supply/src/App.tsx, toga25-supply/src/surface/useFetchSurfaceMeta.ts, toga25-supply/src/contexts/AuthContext.tsx, dbchanges2/Core/2026-07-21a - SalesOrderDecisionSummarySurfaceSeed.sql, dbchanges2/Core/2026-07-21b - SalesOrderDecisionActionSurfaceSeed.sql, dbchanges2/Core/2026-08-21a - SalesOrderDecisionSurfacesReseed.sql, dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_Quad/2026-07-21c - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_Compass/2026-08-04a - ApprovalDetailsAssignedManagerPreferredStage.sql, _underscore/Model/Core/Surface.php, _underscore/Model/Client/AclRecordScript.php, _underscore/Model/Core/RecordScript.php, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Quad/2026-07-01a - GrantSurfacesMetaGroupScriptAcl.sql, dbchanges2/Client_CompassCanada/2026-07-01a - GrantSurfacesMetaGroupScriptAcl.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Client_Compass/2026-07-15f - SalesOrderRecordActionsRemoveDeadConfigRuleOverrides.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, _underscore/Model/Core/SurfaceElement.php, _underscore/Model/Core/Action.php, _underscore/Model/Core/Vocabulary.php, _underscore/Model/Core/VocabularyTerm.php, _underscore/Model/Core/Message.php, _underscore/Model/Client/SurfaceOverride.php, _underscore/Model/Client/MessageTranslation.php, _underscore/Model/Client/ThemeToken.php, _underscore/Model/Core/Page.php, api2/Component/Api/V2/V2.php |
42
43
  | [Table-View Hyperlink Columns (meta → ACL → computed URL → render)](features/tableview-hyperlink-columns.md) | Any 2.0 table-view column can render its value as a clickable link instead of plain text. | _underscore/Model/Client/TableView.php, _underscore/Model/Client/TrackingNumber.php, api2/Component/Api/V2/V2.php, toga2-supply/src/api/toga.ts, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/formatTableData.tsx, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/convertData.tsx, toga2-supply/src/components/ui/Tables/hooks/useDataTableState.tsx, dbchanges2/Client/2026-07-20 - TrackingNumberHyperlinkAndFieldPermission.sql |
43
44
  | [TableView joins (TableViewJoins → SQL) — aliasing, chained multi-hop joins, ACL](features/tableview-joins.md) | `Client_*.TableViewJoins` rows are what let a table view show a column from a table other than its base record. | _underscore/Model/Client/TableView.php, dbchanges2/Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql, dbchanges2/Client_Elite/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql |
44
45
  | [TogaIQ Gateway Client (_Component_Api_Togaiq) — AI generate/translate from 2.0](features/togaiq-gateway-client.md) | `_Component_Api_Togaiq` is the 2.0 framework's client for the **TogaIQ** (Talos) AI gateway. | _underscore/Component/Api/Togaiq/Togaiq.php, _underscore/ApiRequest.php |
@@ -6,10 +6,12 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-03
9
+ updated: 2026-08-25
10
10
  owners: ["jcardinal", "bala", "mhammontree", "apeterson"]
11
11
  files:
12
12
  - _underscore/Model/Client/EmailTemplate.php
13
+ - _underscore/Model/Quad/ApprovalDecision.php
14
+ - _underscore/Model/Quad/SalesOrder.php
13
15
  - _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php
14
16
  - _underscore/Email.php
15
17
  related:
@@ -105,9 +107,31 @@ can keep using `sendEmail($api, ...)`.
105
107
  → `$args = ['userName'=>$name, ...]`). Passing the map as a single positional argument
106
108
  (`sendEmail($api, $uuid, $to, [], [], $vars)`) makes `$args = [0 => [...]]` — a numeric key
107
109
  whose value is an *array*. `replaceTemplateVariables()` then runs `str_replace('{0}', <array>, …)`
108
- → `TypeError: str_replace(): Argument #2 must be of type string, array given`, surfacing as a
109
- production 500 / **EO-1**. `Model/Compass/*` gets this right (spread); the four `Model/Quad/*`
110
+ → `TypeError: str_replace(): Argument #2 ($replace) must be of type string when argument #1
111
+ ($search) is a string`, surfacing as a production 500 / **EO-1** it fails the **whole request**,
112
+ not just one placeholder. `Model/Compass/*` gets this right (spread); the four `Model/Quad/*`
110
113
  call sites were copied from Compass but dropped the `...` — fixed 2026-08-18.
114
+ - **This contract has regressed TWICE.** `c22999d9` (2026-05-19) **removed** the spread and broke
115
+ Quad order-approval emails; `2b0c1470` (2026-08-18) restored it. `sendEmail` has been variadic
116
+ since **2024-12-24**, so the spread has **always** been the correct form and the May commit was
117
+ the regression, not a style change. The contract and both regressions are now recorded on
118
+ `sendEmail()`'s docblock, with a marker comment at each of the four Quad call sites — because
119
+ that is where the mistake actually gets made.
120
+ - **Hardened 2026-08-25 — a mis-spread caller now degrades instead of 500-ing.**
121
+ `replaceTemplateVariables()` (now typed) **skips non-scalar values**, logging the key and the
122
+ **type only — never the value** (template vars carry user names and order numbers, so logging
123
+ them would be a PII violation). Result: a bad caller leaves an **unreplaced placeholder** in the
124
+ email rather than taking down order approval. Verified behaviourally: the old code fatals on the
125
+ no-spread shape, the new code degrades, and the correct spread path is **byte-identical**. The
126
+ spread is still the contract — the guard is a safety net, not a licence to pass a bare array.
127
+ - **⚠ Still seeing this error in an environment does NOT mean the code is wrong — check the deploy
128
+ FIRST.** Verified 2026-08-25: Quad order placement was still 500-ing in the **commerce
129
+ sandbox-client** environment (`EmailTemplate.php:82` via `_Model_Quad_SalesOrder::postPost`)
130
+ even though the fix `2b0c1470` was already committed on `_sandbox-client`. `_underscore` is
131
+ **cloned at build time from a moving per-environment branch**, so the running artifact simply
132
+ predated the commit. That is a **deploy gap, not a code bug** — a redeploy fixes it, and no code
133
+ change should be made. Same known-issue as
134
+ [api2 environment-variable-drives-underscore-branch](../../api2/features/environment-variable-drives-underscore-branch.md).
111
135
  - **`sendEmail()`'s signature is load-bearing for scripted APIs** — the Record Script engine
112
136
  (`api2/Component/Api/V2/V2.php`, ~line 3594) calls the method with `api` as a named
113
137
  argument, so the first param must stay `&$api`. Do not "clean it up" by removing it.
@@ -161,6 +185,19 @@ worker method) in-process instead.
161
185
 
162
186
  ## Change history
163
187
 
188
+ - 2026-08-25 — Added the **deploy-gap** caveat to the spread gotcha: Quad order placement was still
189
+ 500-ing in the commerce **sandbox-client** environment after `2b0c1470` landed, because
190
+ `_underscore` is cloned at build from a moving per-environment branch and the running artifact
191
+ predated the commit. Diagnose the deploy before touching the code. (apeterson)
192
+ - 2026-08-25 — Hardened `replaceTemplateVariables()` (signature typed) to **skip non-scalar values**,
193
+ logging key + **type only** (never the value — template vars carry user names and order numbers), so
194
+ a mis-spread caller degrades to an unreplaced placeholder instead of a 500 that kills the whole
195
+ request. Verified the old code fatals on the no-spread shape, the new code degrades, and the correct
196
+ spread path is byte-identical. Recorded that the spread contract has regressed **twice** —
197
+ `c22999d9` (2026-05-19) removed it and broke Quad order-approval emails, `2b0c1470` (2026-08-18)
198
+ restored it — and that `sendEmail` has been variadic since 2024-12-24, so the spread was always
199
+ correct and May was the regression. Documented the contract on `sendEmail()`'s docblock plus a marker
200
+ comment at each of the four Quad call sites. (apeterson)
164
201
  - 2026-08-18 — Fixed the Quad order-approval/rejection emails' production 500
165
202
  (`str_replace(): Argument #2 must be string`, EO-1): the four `Model/Quad/ApprovalDecision.php`
166
203
  + `Model/Quad/SalesOrder.php` call sites passed the template-vars map as a bare positional
@@ -6,14 +6,19 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-20
9
+ updated: 2026-08-25
10
10
  owners: ["dfranks", "jcardinal", "bala"]
11
11
  files:
12
12
  - _underscore/Component/Api/Netsuite/Netsuite.php
13
+ - _underscore/Trait/Netsuite/SalesOrder.php
14
+ - worker2/Worker/Netsuite/SalesOrder.php
15
+ - worker2/Component/Forecast/SaleImport/SaleImport.php
16
+ - worker2/Worker/Netsuite/Opportunity.php
13
17
  related:
14
18
  - ../../worker2/features/netsuite-salesorder-open-orders-sync.md
15
19
  - ../../worker2/features/netsuite-salesorder-outbound-push.md
16
20
  - ../../worker2/features/netsuite-opportunity-sync.md
21
+ - ../../library/features/netsuite-suiteql-rest-shim.md
17
22
  - ../architecture.md
18
23
  ---
19
24
 
@@ -167,6 +172,40 @@ rather than a bad query. Confirmed live 2026-07-28.
167
172
  Surfaced building the NYCHH asset-tag backfill (see
168
173
  [nychh-asset-tag-backfill](../../worker2/features/nychh-asset-tag-backfill.md)).
169
174
 
175
+ ## NetSuite transaction status sourcing (2.0) — and the 2026.2 REST `status.id` change
176
+
177
+ **`fetchRecord()` returns the REST record UNTOUCHED — there is no central status
178
+ normalization in the shared client.** `fetchRecord()` (`Netsuite.php` ~L215-223) does
179
+ `send('GET', route)`, json-decodes, and hands the raw record back; it never reads or maps
180
+ `status`. So whether a 2.0 consumer is exposed to a change in the REST record's `status`
181
+ field depends entirely on *that consumer*, not on the client. Audited 2026-08-25 — every 2.0
182
+ consumer sources transaction status from a change-safe field:
183
+
184
+ - **`worker2/Worker/Netsuite/SalesOrder.php`** reads `status->refName` (the human-readable
185
+ label) and compares it to `OPEN_STATUSES` text — never `status.id`.
186
+ - **`worker2/Component/Forecast/SaleImport/SaleImport.php`** — `statusName()` (~L845-853) reads
187
+ `status->refName`, matched against `EXCLUDED_STATUSES` text. This is the shared engine that
188
+ Invoice / CashSale / CreditMemo / CashRefund import all delegate to.
189
+ - **`worker2/Worker/Netsuite/Opportunity.php`** — `fetchStatus()` (~L431-444) gets transaction
190
+ status via **SuiteQL** (`SELECT status FROM transaction`), which already returns the letter
191
+ and maps `A`/`C`/`D`. (Its `entityStatus.id` at ~L197 is a *different* field — an opaque
192
+ lookup key, not the transaction status.)
193
+ - **`_underscore/Trait/Netsuite/SalesOrder.php`** — the one text `switch ($nsOrder->status)`
194
+ (~L589-620) is driven by a **SOAP** `$service->get()` record, not REST.
195
+ - **`api2`** makes no NetSuite calls of its own — it only registers the SDK at boot and writes
196
+ one hardcoded outbound status literal (`PENDING_FULFILLMENT`); its `->status` reads are its
197
+ own HTTP response envelope. Not a NetSuite status consumer.
198
+
199
+ > **NetSuite 2026.2 impact: none for 2.0.** NetSuite 2026.2 standardizes the `status.id` field
200
+ > returned by **REST record GETs** for transaction records to single-letter codes (A/B/D/E/V)
201
+ > across all transaction types (today some types return `status.id` as text like `"Open"`);
202
+ > `status.refName` and SuiteQL are unchanged, and SOAP/SuiteTalk is a different API. Because no
203
+ > 2.0 code reads `status.id` off a REST GET body as text, no patch is needed. NetSuite
204
+ > SuiteAnswers ID **89313**. (Not audited: other 2.0 apps that may consume this client —
205
+ > `toga2-supply`, `toga25-supply`, `tools` — check them the same way if touched.) The 1.0 side
206
+ > of the same conclusion is in
207
+ > [`netsuite-suiteql-rest-shim.md`](../../library/features/netsuite-suiteql-rest-shim.md).
208
+
170
209
  ## Logging
171
210
 
172
211
  `send()` calls `setLogging(false)`, so **NetSuite REST request/response bodies are NOT written to
@@ -192,6 +231,14 @@ doc.)
192
231
 
193
232
  ## Change history
194
233
 
234
+ - 2026-08-25 — Documented **2.0 NetSuite transaction-status sourcing** and the conclusion that
235
+ the **NetSuite 2026.2 REST `status.id` text→letter standardization does not affect 2.0**
236
+ (SuiteAnswers 89313). `fetchRecord()` returns the REST record raw (no central status
237
+ normalization), and every audited consumer reads status from `status.refName` or SuiteQL, or
238
+ over SOAP — none reads REST `status.id` as text: worker2 SalesOrder/SaleImport (`refName`),
239
+ worker2 Opportunity (`fetchStatus()` via SuiteQL), `_underscore` SalesOrder trait (SOAP),
240
+ api2 (no NetSuite calls). Discovery/impact-analysis only — no code changed. (jcardinal)
241
+
195
242
  - 2026-08-20 — Added **idempotent creates via `externalId`**: `createRecord()` has no idempotency, so
196
243
  stamp an `externalId` (NetSuite rejects a duplicate within the account) and read the record back
197
244
  with `GET /record/v1/<record>/eid:<externalId>` — the non-SuiteQL fallback, which matters because
@@ -0,0 +1,148 @@
1
+ ---
2
+ title: Per-client sales-order status filter (Surface FILTER_SET → table meta `filterOptions`)
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-25
10
+ owners: [apeterson]
11
+ files:
12
+ - _underscore/Model/Client/TableView.php
13
+ - _underscore/Model/Core/Surface.php
14
+ - _underscore/Model/Client/SalesOrder.php
15
+ - _underscore/Model/Quad/SalesOrder.php
16
+ - _underscore/Model/Prudential/SalesOrder.php
17
+ - toga-blox/src/components/Table/hooks/useFetchTablePageMeta.ts
18
+ - toga-blox/src/api/types.ts
19
+ - dbchanges2/Core/2026-08-24b - SalesOrderStatusFilterSurfaceSeed.sql
20
+ - dbchanges2/Client_Compass/2026-08-24b - SalesOrderStatusFilterHides.sql
21
+ - dbchanges2/Client_CompassCanada/2026-08-24b - SalesOrderStatusFilterHides.sql
22
+ - dbchanges2/Client_Quad/2026-08-24b - SalesOrderStatusFilterHides.sql
23
+ - dbchanges2/Client_Nychh/2026-08-24b - SalesOrderStatusFilterHides.sql
24
+ related:
25
+ - surface-resolver.md
26
+ - item-fulfillment-stage-lifecycle-and-order-status.md
27
+ - ../../dbchanges2/features/surface-layer-schema.md
28
+ - ../../toga-blox/features/table.md
29
+ - ../../api2/features/tableview-field-metadata.md
30
+ ---
31
+
32
+ ## What it is
33
+
34
+ The status-filter dropdown on the sales-orders table is **per client**, driven by a Surface
35
+ `FILTER_SET` rather than by the raw contents of the client's `SalesOrderStatuses` table.
36
+
37
+ Before: `SalesOrderStatuses` is a per-client (`DB_CLIENT`) table, but every tenant was seeded
38
+ identically from `BLANK_CLIENT_DATABASE`, and toga-blox fetched the filter options with an
39
+ **unscoped** `GET /{recordRoute}`. Result: all 12 statuses were offered to every client, including
40
+ statuses that tenant can never reach.
41
+
42
+ After: the backend emits a resolved `filterOptions` list (and the owning `surfaceSlug`) on the
43
+ `TYPE__STATUS` field in table meta; the front end renders exactly what it is given.
44
+
45
+ ## The two layers (why this is not just "hide rows")
46
+
47
+ | Layer | Source | Answers |
48
+ |---|---|---|
49
+ | **EXISTENCE** | the client's own `SalesOrderStatuses` rows | which statuses this tenant *has* — also supplies name + ordering |
50
+ | **BUSINESS** | the Core `FILTER_SET` surface + `SurfaceOverrides` | which of those the tenant should be *offered* |
51
+
52
+ The emitted set is the **intersection**, computed automatically. A client that simply doesn't have a
53
+ status needs no override row at all (this is why **Elite** needs zero rows).
54
+
55
+ ## Data model
56
+
57
+ One `Core.Surfaces` row (**id 44**, slug `sales-order-status-filter`, `type = FILTER_SET`,
58
+ `recordId` = the `sales-order-statuses` record, `parentSurfaceId = 2` `sales-orders-list`), plus one
59
+ **BADGE** `SurfaceElement` per status (**ids 147–160**).
60
+
61
+ - Elements carry **no `labelMessageId`**. Label and colour resolve from the `sales-order-status`
62
+ **vocabulary term** whose `value` matches the element's `config.value`. (Vocabulary terms are
63
+ Core-global — see [surface-resolver](surface-resolver.md).)
64
+ - Per-client hiding is a plain `SurfaceOverrides` row with `IS_VISIBLE = 0`. No new cascade, no
65
+ framework schema change: it rides the existing **base < client < persona < role** override chain.
66
+ - Element count is **14**, not 12 — the set is the **UNION** across tenants (see *Vocabularies are
67
+ not uniform* below).
68
+
69
+ ## How it works (backend — `_Model_Client_TableView`)
70
+
71
+ Two new private helpers on the MAIN table-meta builder:
72
+
73
+ - `getStatusFilterSurfaceSlug()` — loads the `FILTER_SET` surface by `Surfaces.recordId`.
74
+ - `getStatusFilterOptions()` — calls `_Model_Core_Surface::resolve()` (so the whole override cascade
75
+ applies) and subtracts the hidden slugs from the client's own status rows.
76
+
77
+ Both return **null** when no surface is configured, so an unconfigured table keeps its previous
78
+ behaviour instead of rendering an empty dropdown. Only the **main** meta builder needed this — the
79
+ nested-table builder emits no `isFilterable`. (commit `01b135f4`)
80
+
81
+ ## How it works (front end — toga-blox)
82
+
83
+ `useFetchTablePageMeta.ts` now **prefers server-supplied `filterOptions`** and skips the
84
+ `recordRoute` fetch for those fields. The query list and the index lookup share one
85
+ `needsFetchedOptions` predicate so the two can never skew. `src/api/types.ts`: `filterOptions[].uuid`
86
+ is now optional and `surfaceSlug` is declared on `TableViewField`. (commit `dded92a6` — this
87
+ implements a TODO blox already carried in that file.)
88
+
89
+ > blox `dist/` is gitignored — consumers only see this after `npm run build`.
90
+
91
+ ## Onboarding a client: auditing which statuses it can actually reach
92
+
93
+ There are **three independent sources** of a reachable status. Check all three — none alone is
94
+ sufficient:
95
+
96
+ 1. **Static CASE branches** in `_Model_<Client>_SalesOrder::_status()`.
97
+ 2. **Explicit stage** — `SalesOrders.salesOrderStageId` → `SalesOrderStages.salesOrderStatusId`.
98
+ 3. **Approval stage** — `SalesOrderStatuses.pendingApprovalTemplateStageId` → `ApprovalTemplateStages`.
99
+
100
+ **Trap — counting `SalesOrderStages` rows measures CONFIGURATION, not USAGE.** `SalesOrderStages`
101
+ has **no `salesOrderId`**; it is a lookup table. To count real orders you must join through
102
+ `SalesOrders.salesOrderStageId`.
103
+
104
+ **Trap — scraping `SLUG__*` constants over-reports.** `_Model_Client_ItemFulfillmentStatus::SLUG__SHIPPED`
105
+ and `APP_SLUG__COMMERCE` are *different vocabularies*, not emitted sales-order statuses.
106
+
107
+ Usage data is an input, not the verdict: the **account team overrides it**. Compass's hide list
108
+ deliberately hides `billed` / `closed` / `pendingBilling` even though ~40k orders sit in those
109
+ states from the NetSuite billing sync.
110
+
111
+ ### Current hide lists (2026-08-24)
112
+
113
+ | Client | Hidden |
114
+ |---|---|
115
+ | Compass USA / Compass Canada | `picked`, `packed`, `billed`, `closed`, `pendingBilling`, `pendingBillingPartiallyFulfilled` |
116
+ | Quad | `pendingInitialApproval`, `pendingBilling`, `pendingBillingPartiallyFulfilled` |
117
+ | NYCHH | `pendingInitialApproval`, `pendingApproval` (it has **no** `ApprovalTemplateStages` rows) |
118
+ | Elite | none needed — existence-intersection covers it |
119
+
120
+ ## Gotchas
121
+
122
+ - **Client status vocabularies are NOT uniform.** Compass, Compass Canada, Quad and NYCHH carry the
123
+ same 12 slugs; **Elite differs** — it has `delivered` and `shipped` where the others have
124
+ `picked` / `packed`. Hence the Core element set is the 14-value union.
125
+ - **⚠ Prudential is a different vocabulary entirely and is excluded from this work.**
126
+ `_Model_Prudential_SalesOrder::_status()` returns `PurchaseOrderStages.name` (POD, Ready To Ship,
127
+ Cancelled, Rejected, Open, Received, Accepted, Configuration, In Progress) while
128
+ `Client_Prudential.SalesOrderStatuses` holds only `picked`/`packed` — so its status filter would
129
+ offer two values matching **zero** orders. Moot today (Prudential doesn't show the status column),
130
+ but the wiring is structurally wrong; do not "fix" it by adding override rows. Its `_status()` also
131
+ uses `LIMIT 1` with **no `ORDER BY`** on a non-unique join — the exact pattern the SA135481 rule in
132
+ [`2.0/standards/backend-php.md`](../../../standards/backend-php.md) forbids.
133
+ - **⚠ OPEN — Quad and `canceled`.** Quad's `_status()` has no `canceled` branch and zero
134
+ canceled-stage orders, yet the account team says Quad does use canceled. Possibly a bug. Flagged,
135
+ **not fixed**.
136
+ - Table/surface meta is cached with `Infinity` staleTime in blox — a hard reload is required after a
137
+ metadata DB change (see [toga-blox table](../../toga-blox/features/table.md)).
138
+
139
+ ## Change history
140
+ - 2026-08-25 — Built the feature end to end: Core `FILTER_SET` surface 44 + BADGE elements 147–160
141
+ (labels/colours from the `sales-order-status` vocabulary, per-client hiding via `SurfaceOverrides`
142
+ `IS_VISIBLE=0`); `_Model_Client_TableView` emits `surfaceSlug` + `filterOptions` on `TYPE__STATUS`
143
+ via `getStatusFilterSurfaceSlug()` / `getStatusFilterOptions()` (null when unconfigured, so
144
+ behaviour is unchanged for tables with no surface); toga-blox `useFetchTablePageMeta` prefers
145
+ server-supplied options and skips the per-field `recordRoute` fetch. Recorded the two-layer
146
+ existence/business model, the three-source status-reachability audit method (+ the
147
+ `SalesOrderStages`-is-a-lookup-table and `SLUG__*`-scrape traps), the non-uniform Elite vocabulary,
148
+ and the structurally-wrong Prudential wiring. (apeterson)
@@ -6,9 +6,10 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-24
9
+ updated: 2026-08-25
10
10
  owners: [jcardinal, apeterson]
11
11
  files:
12
+ - _underscore/Model/Client/Language.php
12
13
  - dbchanges2/Core/2026-08-21 - SalesOrderDecisionSurfacesReseed.sql
13
14
  - dbchanges2/Core/2026-08-24 - RestoreApproveDenyRowActionsVisibility.sql
14
15
  - dbchanges2/Client_Quad/2026-08-24 - ProdPortApprovePoNumberEnabledRule.sql
@@ -57,6 +58,7 @@ related:
57
58
  - ../../dbchanges2/features/surface-layer-schema.md
58
59
  - ../../api2/features/surface-meta-option.md
59
60
  - acl-permission-chain.md
61
+ - sales-order-status-filter-surface.md
60
62
  ---
61
63
 
62
64
  ## What it is
@@ -501,6 +503,27 @@ Core record grants + their logic-group expressions all evaluate `all`/`"1"`. The
501
503
  including refresh, without each auth flow having to embed the id). (This is a **separate** concern from
502
504
  `id.language` driving the data-translation sidecar — see
503
505
  [language-translation-layer](../../api2/features/language-translation-layer.md); do not conflate.)
506
+ **Extended 2026-08-25 — the 07-21 fix still left EVERY API-AUTHENTICATED request untranslated, for
507
+ every client.** The fallback chain stopped at `id.language`, but **api2 embeds `id.language` on USER
508
+ auth only** (`V2.php` ~L1501; the comment at ~L1862 states *"user auth only; null for API auth"*).
509
+ So an API-auth request carried neither claim, `_resolveLanguageId` returned `0`, and `_loadMessages`
510
+ skipped the `MessageTranslations` overlay — silently serving `Core.Messages.defaultValue`.
511
+ **Fix:** added `_Model_Client_Language::CODE_BASE = 'en'` and gave `_resolveLanguageId` a final
512
+ fallback to the **client's base-language row**. Precedence is now:
513
+ 1. `id.client.languageId` — the future user-preference slot;
514
+ 2. `id.language` code;
515
+ 3. **client base language** (`CODE_BASE`);
516
+ 4. `0` → Core defaults.
517
+
518
+ Steps 1–2 still win, so a real user preference is never overridden — this matters because language
519
+ **will** become a user preference (in TOGa Commerce the surface layer's language is already user-set).
520
+ - **Symptom shape that identifies this class of bug:** language-*independent* `SurfaceOverrides`
521
+ (e.g. `ENABLED_RULE`) apply correctly while **only the message text** falls back to the Core
522
+ default. Rules working + text not working ⇒ suspect `languageId`, not the override seeding.
523
+ - **⚠ DIAGNOSTIC TRAP: `audience.language` in the V2 response is NOT evidence the JWT carries a
524
+ language.** `V2.php` ~L2100 defaults it (`$this->language ?? 'en'`), so it reads `"en"` on tokens
525
+ with no language claim at all. **Decode the JWT instead.** Tell-tale of an API-auth token:
526
+ `id.client` has **no `user` node**.
504
527
  - **A `SurfaceOverrides` row with `attribute='CONFIG'` carrying a RULE value is silently DEAD —
505
528
  encode rules as `VISIBILITY_RULE`/`ENABLED_RULE`, never as `CONFIG`.** `_castOverride` routes the
506
529
  `CONFIG` attribute into `$config`, **NOT** `$visibilityRule`/`$enabledRule`; a `CONFIG` row whose
@@ -583,6 +606,19 @@ Core record grants + their logic-group expressions all evaluate `all`/`"1"`. The
583
606
  match Compass, a follow-up migration aligning both `meta` and `meta-group` to roles 1,3,4 is needed.
584
607
 
585
608
  ## Change history
609
+ - 2026-08-25 — Fixed a **whole-platform gap**: Surface message translations were inert for **every
610
+ API-authenticated request, for every client**. `_resolveLanguageId` returned `0` when the JWT carried
611
+ neither `id.client.languageId` nor `id.language`, and `0` makes `_loadMessages` skip the
612
+ `MessageTranslations` overlay entirely — silently serving `Core.Messages.defaultValue`. api2 embeds
613
+ `id.language` on **user auth only** (`V2.php` ~L1501; comment ~L1862), so all API-auth traffic hit it.
614
+ Fix: added `_Model_Client_Language::CODE_BASE = 'en'` and a final fallback to the client's
615
+ base-language row, making precedence `id.client.languageId` → `id.language` → client base language →
616
+ `0`; steps 1–2 still win so a future user preference is never overridden (language will become a user
617
+ preference — TOGa Commerce already sets it per user). Recorded the identifying symptom shape
618
+ (language-independent overrides like `ENABLED_RULE` work while **only** message text falls back) and
619
+ the diagnostic trap that **`audience.language` is not evidence of a language claim** — `V2.php` ~L2100
620
+ defaults it to `'en'`; decode the JWT, and note that an API-auth token's `id.client` has no `user`
621
+ node. (apeterson)
586
622
  - 2026-08-24 — **Three corrections that SUPERSEDE prior claims in this doc.** (1) The 2026-08-21
587
623
  "client `SurfaceOverride` seeding is NOT cluster-safe / no compliant workaround exists / needs a
588
624
  cto-level decision" section is **RESOLVED**: Core seed files now assign **explicit team-reserved id
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-24
9
+ updated: 2026-08-25
10
10
  owners: ["bala", "mhammontree", "apeterson"]
11
11
  files:
12
12
  - api2/.ebextensions/git.php
@@ -50,6 +50,60 @@ Cost of not knowing this: hours of debugging a feature that was, in fact, simply
50
50
  - **`api2/.ebextensions/php_include_underscore.config`** — writes
51
51
  `include_path = ".:/var/www/html/_underscore"`. See *the 1.0 library is unreachable* below.
52
52
 
53
+ ## ⚠ TWO independent mechanisms deliver `_underscore` — BOTH must point at the right branch
54
+
55
+ Verified 2026-08-25 at the cost of a multi-hour investigation on `sandbox-client`. `_underscore`
56
+ reaches an environment by **two separate paths**, configured in two different places, and **either
57
+ one alone can silently serve the wrong branch**:
58
+
59
+ | # | Mechanism | Where the branch is configured | Visible in the repo? |
60
+ |---|---|---|---|
61
+ | 1 | The **CodePipeline `_underscore` source action** | **In AWS**, on the pipeline's source action (its own `BranchName` / branch trigger) — *separate from* the api2 environment it deploys to | **No** — nothing in the repo reveals it |
62
+ | 2 | The `.ebextensions/git.php` **prebuild clone** | `git.<env>.json`'s `branch` key, else `'_' . strtolower(ENVIRONMENT)` | Yes |
63
+
64
+ Checking only #2 (which is the one you can `grep`) is the trap: it can be perfectly correct while
65
+ #1 quietly feeds the environment a different branch.
66
+
67
+ **Concrete incident (2026-08-25).** The `ApiSandboxClient` environment ran `_underscore` from
68
+ **`_production`** because the pipeline's `_underscore` **source action's branch trigger** was set to
69
+ `_production` — even though `git.sandbox-client.json` was correct (no `branch` key). This was
70
+ invisible because **every developer-facing signal said otherwise**:
71
+
72
+ - the pipeline UI displayed the `_sandbox-client` merge commit (`2b523788`);
73
+ - the pipeline reported **"All actions succeeded"**;
74
+ - the clone deletes `.git`, so the instance carries **no commit id** to contradict it.
75
+
76
+ Two unrelated "data bugs" were really one stale box: the Quad approval-email `str_replace()` 500
77
+ (needs `2b0c1470`, Aug 18) and the Quad approve-tooltip falling back to Core defaults (needs
78
+ `3d92f973`, Jul 21). `git merge-base --is-ancestor` proved **neither commit is on `_production`** —
79
+ which is what turned a code hunt into a deploy fix.
80
+
81
+ **Rule: when a merged fix isn't live, verify the pipeline's `_underscore` source action branch in
82
+ AWS *as well as* `git.<env>.json`.** "All actions succeeded" and a correct commit id in the pipeline
83
+ UI are **not** evidence the right branch was deployed.
84
+
85
+ ### Diagnostic: a stack trace's LINE NUMBERS objectively pin which version of a file is running
86
+
87
+ This is the technique that broke the 2026-08-25 deadlock, and it works with no server access.
88
+ Take the reported line numbers and compare them against the same file at each **candidate commit**
89
+ (`git show <commit>:<path>`). Only one version matches.
90
+
91
+ Worked example — the reported trace (`str_replace` at line **82**, `dispatch` at **30**,
92
+ `setSubject` at **73**) was an exact match for the **pre-fix** `EmailTemplate.php`; the fixed file
93
+ has those same calls at **115 / 41 / 84**. Proof the box was stale, not the code wrong.
94
+
95
+ Companion facts from the same investigation:
96
+
97
+ - **Runtime paths in traces are `/var/app/current/...`** — *not* the `/var/www/html/_underscore`
98
+ `include_path` entry. Don't dismiss a trace because the path looks unfamiliar.
99
+ - **"My coworker sees the code on branch X" ≠ "the server runs branch X."** A passing `git log` or
100
+ a clean local checkout says nothing about the instance. Keep the two claims separate.
101
+ - **`git merge-base --is-ancestor <fix-commit> <branch>`** is the cheap, objective test for "is this
102
+ fix on that branch?" — run it before theorizing about data.
103
+ - **`git ls-remote` cheaply validates the committed deploy PAT.** One command eliminated the
104
+ "expired token broke the clone" hypothesis. (See the PAT security gotcha below — verify the token
105
+ *works*, never print or copy its value.)
106
+
53
107
  ## How to determine what a tier is actually running
54
108
 
55
109
  1. **Read `ENVIRONMENT` on the instance** — `/opt/elasticbeanstalk/bin/get-config environment -k
@@ -166,6 +220,18 @@ one.
166
220
 
167
221
  ## Change history
168
222
 
223
+ - 2026-08-25 — Recorded the **second delivery mechanism**: besides `.ebextensions/git.php`, the
224
+ **CodePipeline `_underscore` source action has its own branch trigger configured in AWS**, and it
225
+ can serve the wrong branch while `git.<env>.json` is perfectly correct. Live case: `ApiSandboxClient`
226
+ ran `_production` because that source action was pointed at `_production`, while the pipeline UI
227
+ showed the `_sandbox-client` merge commit `2b523788` and reported "All actions succeeded" — and the
228
+ clone deletes `.git`, so the instance has no commit id to contradict it. Two apparently unrelated
229
+ Quad bugs (the approval-email `str_replace()` 500 needing `2b0c1470`; the approve-tooltip Core-default
230
+ fallback needing `3d92f973`) were one stale box, proven by `git merge-base --is-ancestor`. Added the
231
+ **stack-trace-line-number** technique for objectively pinning which version of a file is running
232
+ (82/30/73 = pre-fix vs 115/41/84 = fixed), plus: runtime paths are `/var/app/current/...` not the
233
+ `include_path`; "a coworker sees it on branch X" is not "the server runs branch X"; and `git ls-remote`
234
+ cheaply disproves an expired-PAT hypothesis. (apeterson)
169
235
  - 2026-08-24 — Recorded the **inverse** of the branch-pin trap: an **absent** `"branch"` key in
170
236
  `git.*.json` is **correct and intentional** — `git.php` derives `_` + `strtolower(ENVIRONMENT)`
171
237
  when the key is missing, and `git.sandbox-client.json`/`git.json` rely on that; do not "restore"
@@ -6,8 +6,8 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-20
10
- owners: [mhammontree, tcox, jcardinal, ajean, bala]
9
+ updated: 2026-08-25
10
+ owners: [mhammontree, tcox, jcardinal, ajean, bala, apeterson]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - api2/Component/Api/V2/Response/Response.php
@@ -73,6 +73,14 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
73
73
  3. **EV-6 vs EZ-1** are the two halves of exposing a scripted API: EV-6 = no `Core.RecordScripts`
74
74
  route (the segment falls through to record lookup); EZ-1 = the route exists but there's no
75
75
  `AclRecordScripts` dispatch grant for the caller's role.
76
+ 4. **A MALFORMED bearer token returns EO-1 500, not EN-3 401.** In `V2.php` (~line 1840) the token
77
+ header/payload segments are `base64`-decoded and `json_decode`d; a malformed token decodes to
78
+ `null`, and `property_exists()` on `null` throws a **`TypeError`** — which (per the escalation
79
+ below) surfaces as an **EO-1 500**. So an obviously-invalid token looks like a server fault
80
+ instead of an auth failure. The same code at ~line 826 already had the correct `is_object()`
81
+ guard; adding it to both header and payload restores **EN-3 401**. ⚠ As of 2026-08-25 this guard
82
+ is **written but stashed/uncommitted** — expect the 500 in any deployed environment. Relates to
83
+ the 2026-08-21 cross-client stale-token finding.
76
84
  5. **EO-1 can be a masked PHP warning, not an operation error.** Sentry's `ErrorHandler` in
77
85
  api2 **escalates PHP warnings/notices into thrown exceptions**, so a latent "read property on
78
86
  bool" or "undefined variable" surfaces to the client as an **HTTP 500 (EO-1)** rather than a
@@ -273,6 +281,11 @@ Full mechanics:
273
281
  - The response-envelope shape and code families: see [api2 architecture](../architecture.md).
274
282
 
275
283
  ## Change history
284
+ - 2026-08-25 — Added the diagnostic that a **malformed bearer token returns EO-1 500 instead of
285
+ EN-3 401**: the header/payload `json_decode` yields `null` and `property_exists()` throws a
286
+ `TypeError` (`V2.php` ~1840); the sibling check at ~826 already guarded with `is_object()`.
287
+ Guard written but **stashed/uncommitted**, so the 500 is still live in deployed environments.
288
+ (apeterson)
276
289
  - 2026-08-20 — Added a **fourth `EO-1` case: an exception message that is literally `[]`.** It is a
277
290
  message-array truthiness throw, not an operation error — `internalApiRequest`'s throw is guarded on
278
291
  the **shared** `response->messages`, and `V2.php:2416`'s `json_encode(array_filter(...))` is the
@@ -6,8 +6,8 @@ project: API
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-08-10
10
- owners: ["jcardinal", "mhammontree", "bala"]
9
+ updated: 2026-08-25
10
+ owners: ["jcardinal", "mhammontree", "bala", "apeterson"]
11
11
  files:
12
12
  - api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh
13
13
  - api2/ebs/register_instance_to_shared_application_load_balancer.php
@@ -65,6 +65,39 @@ are actually changing before you merge anything:
65
65
  - **`api2` has no deploy workflow.** Its only GitHub Action is the `TRUE-#####:` commit-message
66
66
  linter, so **EB deploys are manual**. Do not wait for CI to ship a framework change.
67
67
 
68
+ ### ⚠ The `_underscore` SOURCE ACTION has its own branch trigger — and a green pipeline hides a wrong one
69
+
70
+ The Source stage's **`_underscore` action carries its own `BranchName`/branch trigger, configured in
71
+ AWS and independent of the api2 environment it deploys to.** Nothing in the repo reveals it. This is a
72
+ **second**, separate way for an environment to run the wrong framework branch — the first being
73
+ `.ebextensions/git.<env>.json` (above). Both must be right.
74
+
75
+ **Incident 2026-08-25 (`ApiSandboxClient`, multi-hour investigation).** The `_underscore` source
76
+ action was pointed at **`_production`**, so the environment ran `_production` framework code, while:
77
+
78
+ - `git.sandbox-client.json` was **correct** (no `branch` key — the intended design), so the one
79
+ config you can `grep` exonerated itself;
80
+ - the pipeline UI displayed the **`_sandbox-client` merge commit `2b523788`**;
81
+ - the pipeline reported **"All actions succeeded"**;
82
+ - the prebuild clone deletes `.git`, so the instance carries **no commit id** to contradict any of it.
83
+
84
+ Two apparently unrelated live bugs (a Quad approval-email 500 and a Quad tooltip falling back to Core
85
+ defaults) were the **same** stale box; `git merge-base --is-ancestor <fix> _production` proved neither
86
+ fix was on the branch actually deployed.
87
+
88
+ **Triage rule:** a green pipeline showing the right commit proves the *source artifact* was fetched —
89
+ it does **not** prove which **branch** each source action watches. Read the source action config:
90
+
91
+ ```bash
92
+ aws codepipeline get-pipeline --name "$PIPELINE" --region "$REGION" \
93
+ --query 'pipeline.stages[?name==`Source`].actions[].{name:name,repo:configuration.FullRepositoryId,branch:configuration.BranchName}'
94
+ ```
95
+
96
+ Then confirm the fix is actually on that branch with
97
+ `git merge-base --is-ancestor <fix-commit> origin/<branch>`. See
98
+ [ENVIRONMENT drives the `_underscore` branch](../features/environment-variable-drives-underscore-branch.md)
99
+ for the companion mechanism and the stack-trace-line-number technique for proving a box is stale.
100
+
68
101
  ### Debugging a beta env without Sentry — `Logs_<Client>.Api`
69
102
  `Logs_<Client>.Api` is a **full server-side request log**: `queryString`, request/response payloads,
70
103
  `direction` (`IN`/`OUT`), `instanceId`, and `executionTime`. It is the reliable way to see what a
@@ -200,6 +233,13 @@ aws codeconnections get-connection --connection-arn "<CONN_ARN>" --region "$REGI
200
233
  ```
201
234
 
202
235
  ## Change history
236
+ - 2026-08-25 — Documented that the Source stage's **`_underscore` action has its own AWS-side branch
237
+ trigger**, independent of `git.<env>.json` and invisible in the repo — a second way to silently run
238
+ the wrong framework branch. `ApiSandboxClient` ran `_production` from a mis-set source action while
239
+ `git.sandbox-client.json` was correct, the pipeline UI showed the `_sandbox-client` merge commit
240
+ `2b523788`, and the run reported "All actions succeeded"; the clone deletes `.git`, so the instance
241
+ had no commit id to contradict it. Added the `get-pipeline` query for reading each source action's
242
+ `BranchName` and the `git merge-base --is-ancestor` confirmation step. (apeterson)
203
243
  - 2026-08-10 — Added three deploy-verification facts from the Elite sales-order push: (1) **staleness
204
244
  is per-INSTANCE** — the worker2 web instance behind `worker.beta.togahub.com`
205
245
  (`i-0262cfdc6cb97a3d2`) ran current code while the **SQS consumer** (`i-0be8ef2e550dec05b`, env
@@ -3,7 +3,7 @@
3
3
  | Doc | Summary | Files |
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
- | [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-08-21 - SalesOrderDecisionSurfacesReseed.sql, dbchanges2/Core/2026-08-24 - RestoreApproveDenyRowActionsVisibility.sql, dbchanges2/Client_Quad/2026-08-24 - ProdPortApprovePoNumberEnabledRule.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 |
6
+ | [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-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 |
7
7
  | [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/ |
8
8
  | [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 |
9
9
  | [Local vs prod MySQL config parity — why “it passed locally” is not evidence](workflows/local-vs-prod-mysql-config-parity.md) | Several migration failures that look like "prod-only bugs" are actually **per-machine MySQL server-configuration differences**. | |
@@ -6,12 +6,18 @@ project: Database Changes
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-24
9
+ updated: 2026-08-25
10
10
  owners: [jcardinal, apeterson]
11
11
  files:
12
+ - dbchanges2/Core/2026-08-24b - SalesOrderStatusFilterSurfaceSeed.sql
13
+ - dbchanges2/Client_Compass/2026-08-24b - SalesOrderStatusFilterHides.sql
14
+ - dbchanges2/Client_CompassCanada/2026-08-24b - SalesOrderStatusFilterHides.sql
15
+ - dbchanges2/Client_Quad/2026-08-24b - SalesOrderStatusFilterHides.sql
16
+ - dbchanges2/Client_Nychh/2026-08-24b - SalesOrderStatusFilterHides.sql
12
17
  - dbchanges2/Core/2026-08-21 - SalesOrderDecisionSurfacesReseed.sql
13
18
  - dbchanges2/Core/2026-08-24 - RestoreApproveDenyRowActionsVisibility.sql
14
19
  - dbchanges2/Client_Quad/2026-08-24 - ProdPortApprovePoNumberEnabledRule.sql
20
+ - dbchanges2/Client_Quad/2026-07-21b - ApproveDisabledTooltipPoNumber.sql
15
21
  - dbchanges2/Client_Quad/2026-08-21 - SalesOrderDecisionSummaryOverride.sql
16
22
  - dbchanges2/Client_Compass/2026-08-24 - ProdPortApprovalsGateAndApproveStepTwoRule.sql
17
23
  - dbchanges2/Client_CompassCanada/2026-08-24 - ProdPortApprovalsGateAndApproveStepTwoRule.sql
@@ -476,6 +482,47 @@ Blocks assigned by `Core/2026-08-21 - SalesOrderDecisionSurfacesReseed.sql`: **`
476
482
  202–222**, **`Core.Surfaces` 41–43**, **`Core.SurfaceElements` 125–146**. A new Core surface seed
477
483
  takes the next unused block and writes ids explicitly.
478
484
 
485
+ Blocks assigned by `Core/2026-08-24b - SalesOrderStatusFilterSurfaceSeed.sql`: **`Core.Surfaces` 44**
486
+ (`sales-order-status-filter`, `type=FILTER_SET`), **`Core.SurfaceElements` 147–160** (one BADGE per
487
+ sales-order status; no `Core.Messages` rows — labels come from the `sales-order-status` vocabulary).
488
+ See [sales-order-status-filter-surface](../../_underscore/features/sales-order-status-filter-surface.md).
489
+
490
+ ### 🚨 VERIFY the block is free, and never seed literal ids with `INSERT IGNORE`
491
+
492
+ **Incident, 2026-08-24 — a reserved block was quoted as "highest used" and read as "next free", and
493
+ `INSERT IGNORE` hid the resulting collision.** The first attempt at the status-filter seed took
494
+ `Surfaces` **43** and `SurfaceElements` **145–146+**. But 43 was already `sales-order-deny-action`
495
+ and 145–146 were its toast elements. Because the seed used `INSERT IGNORE`:
496
+
497
+ - the new surface row was **silently never created**;
498
+ - its 12 elements **attached themselves to the deny-action surface**;
499
+ - the per-client override rows then pointed at unrelated elements — NYCHH and Quad ended up hiding
500
+ that surface's **success/error toasts**.
501
+
502
+ Cleaned up locally **by uuid**, never by id range (an id range would have taken the legitimate
503
+ deny-action rows with it). Two durable rules follow:
504
+
505
+ 1. **Verify a reserved block is actually free before writing the seed** — `SELECT MAX(id)` *and*
506
+ `SELECT id FROM … WHERE id BETWEEN <lo> AND <hi>`. Never take quoted ids on trust, and always
507
+ confirm whether the number you were handed means **"next free"** or **"highest used"**.
508
+ 2. **Use plain `INSERT`, never `INSERT IGNORE`, for any row with a LITERAL id.** `IGNORE` converts a
509
+ primary-key collision — the one error that must stop the migration — into a silent mis-wiring.
510
+ Idempotency for literal-id seeds belongs in a `NOT EXISTS` guard on a *semantic* key, or in a
511
+ paired `UPDATE`, not in `IGNORE`.
512
+
513
+ This is the **same failure class** as the earlier Quad wrong-surface rollout and as the NYCHH stale
514
+ Core-id landmine below: a surface/element id that resolves to *something* is never self-evidently the
515
+ *right* something.
516
+
517
+ **⚠ Pre-reserved-block `Core.Messages` rows are plain `AUTO_INCREMENT` and DO drift per environment.**
518
+ The reserved-id discipline above starts at the 2026-08-21 reseed (`Messages` 202–222); **older**
519
+ `Core.Messages` rows were auto-assigned and are **not** one of the two team-maintained id tables, so an
520
+ id can differ between local, sandbox and prod. Verified 2026-08-25 on sandbox-client: `Core.Messages`
521
+ **23 / 24 / 25** = `salesOrder.action.approve` / `.tip` / `.disabled`, and `Client_Quad.Languages` has
522
+ exactly one row, **id 1 = `en`**. In that instance **id 25 matched on both local and sandbox, so drift
523
+ was NOT the cause of the bug being chased** — worth recording so the next person neither assumes drift
524
+ nor rules it out without checking.
525
+
479
526
  **Client override house style** (see the 2026-08-24 prod-port files for Compass / Compass Canada /
480
527
  Quad): Core ids and `messageId`s inlined as literals; only **client-local** ids resolved at runtime
481
528
  (e.g. `SET @adminRoleId = (SELECT id FROM Roles WHERE …)`, deliberately a lookup because role ids
@@ -483,6 +530,24 @@ differ per client *and* per environment — Compass Admin = 4, Compass Canada Ad
483
530
  **every statement guarded on `@adminRoleId IS NOT NULL`** so a lookup miss no-ops instead of writing
484
531
  garbage.
485
532
 
533
+ ### Worked before/after: `Client_Quad` approve-tooltip — the same override, wrong then right
534
+
535
+ Two real files on the same subject, one violating the cluster-isolation HARD RULE and one compliant.
536
+ Read them side by side; this is the clearest example in the repo of what the rule actually costs.
537
+
538
+ | | `Client_Quad/2026-07-21b - ApproveDisabledTooltipPoNumber.sql` ❌ | `Client_Quad/2026-08-24a - ProdPortApprovePoNumberEnabledRule.sql` ✅ |
539
+ |---|---|---|
540
+ | Cross-cluster | Joins **`Core.Messages` to `Client_Quad.Languages`** — a live violation | Core ids **inlined as literals**; Core-free |
541
+ | Where it runs | **Local only** (all-in-one endpoint). Can **never** run on prod or sandbox — separate clusters | Runs anywhere |
542
+ | UUIDs | MySQL **`UUID()`** (v1) — violates the v4 rule | Pre-generated **v4 UUID literals** |
543
+ | Qualification | Qualifies its own tables | Unqualified (client-DB-relative) |
544
+
545
+ **`07-21b` is a known-superseded landmine, deliberately left in place.** It is superseded by
546
+ `2026-08-24a` and the developer has **explicitly deferred acting on it** — it is recorded here so the
547
+ next person recognizes it rather than "discovering" it, and does **not** open work on it. The
548
+ takeaway is the pattern, not a cleanup task: a green local run proves nothing about cluster safety,
549
+ and a `Client_*` file that names `Core.*` is broken **even though it works on your machine**.
550
+
486
551
  ### Ship-together dependency: the approvals GATE and the Approve step-two RULE
487
552
  The approvals-gate marker and the Approve `ENABLED_RULE` **must ship in the same release**. The
488
553
  `stepTwoAssigned` predicate reads `order.currentStage` / `order.stages`, which are only fetched when
@@ -666,6 +731,27 @@ rule resumes.
666
731
  override is added). **Open follow-up.**
667
732
 
668
733
  ## Change history
734
+ - 2026-08-25 — Added a **worked before/after cluster-isolation example** on a real client migration:
735
+ `Client_Quad/2026-07-21b - ApproveDisabledTooltipPoNumber.sql` joins **`Core.Messages` to
736
+ `Client_Quad.Languages`**, so it runs locally (all-in-one endpoint) and can **never** run on prod or
737
+ sandbox — plus MySQL `UUID()` (v1) and self-qualified tables — versus the compliant
738
+ `Client_Quad/2026-08-24a - ProdPortApprovePoNumberEnabledRule.sql` (Core ids inlined, unqualified,
739
+ v4 UUID literals). `07-21b` is recorded as a **known-superseded landmine the developer has
740
+ explicitly deferred** — do not open work on it. Also recorded that **pre-reserved-block
741
+ `Core.Messages` rows are plain `AUTO_INCREMENT` and do drift per environment** (they are not one of
742
+ the two team-maintained id tables); verified on sandbox-client that Messages **23/24/25** =
743
+ `salesOrder.action.approve`/`.tip`/`.disabled` and `Client_Quad.Languages` holds one row, id 1 =
744
+ `en`, and that id 25 matched local **and** sandbox — so drift was **not** the cause in that case.
745
+ (apeterson)
746
+ - 2026-08-25 — Reserved **`Core.Surfaces` 44** + **`SurfaceElements` 147–160** for the
747
+ `sales-order-status-filter` `FILTER_SET` seed (`Core/2026-08-24b`; no Messages rows — labels come
748
+ from the `sales-order-status` vocabulary). Recorded the **reserved-block collision incident**: the
749
+ block was quoted as "highest used" but read as "next free", so the seed targeted the live
750
+ `sales-order-deny-action` surface, and `INSERT IGNORE` **silently** dropped the surface row while
751
+ attaching 12 elements to the wrong surface (NYCHH/Quad then hid that surface's toasts). Two rules
752
+ added — verify a block is free (`SELECT MAX(id)` + range check, and confirm next-free vs
753
+ highest-used) and **never `INSERT IGNORE` a row with a literal id**; clean up by uuid, never by id
754
+ range. (apeterson)
669
755
  - 2026-08-24 — Hunted the migrations missed by the 2026-08-21 hand-picked prod push and built
670
756
  forward-only prod-safe ports. **Settled the prod-safe authoring pattern** (new section): Core seeds
671
757
  assign **explicit reserved id literals** (`Messages` 202–222, `Surfaces` 41–43, `SurfaceElements`
@@ -9,6 +9,6 @@
9
9
  | [BaseInput (react-hook-form field factory)](features/base-input.md) | `BaseInput` is a **form-field factory** driven by react-hook-form. | toga-blox/src/components/BaseInput/BaseInput.tsx, toga-blox/src/components/BaseInput/BaseInput.types.ts, toga-blox/src/components/BaseInput/BaseInput.module.css, toga-blox/src/components/BaseInput/components |
10
10
  | [Primary Table templates (server/client, sizing, virtualization)](features/primary-table-templates.md) | `src/templates/PrimaryTable/` is the **production, wired-up table** built on the [Table component](table.md). | toga-blox/src/templates/PrimaryTable/PrimaryTable.tsx, toga-blox/src/templates/PrimaryTable/PrimaryTableServerTemplate.tsx, toga-blox/src/templates/PrimaryTable/PrimaryTableClientTemplate.tsx, toga-blox/src/templates/PrimaryTable/PrimaryTableHeaderCell.tsx, toga-blox/src/templates/PrimaryTable/PrimaryTableBodyCell.tsx, toga-blox/src/templates/PrimaryTable/PrimaryTableRow.tsx, toga-blox/src/templates/PrimaryTable/PrimaryTableExpandableRow.tsx, toga-blox/src/templates/PrimaryTable/types.ts |
11
11
  | [TableRecordModal (record-detail modal shell)](features/table-record-modal.md) | `TableRecordModal` is a **generic, presentational modal shell** for showing a single table record (row) in detail — typically opened from a table row click. | toga-blox/src/components/TableRecordModal/TableRecordModal.tsx, toga-blox/src/components/TableRecordModal/index.ts, toga-blox/src/components/TableRecordModal/tableRecordModal.module.css |
12
- | [Table component (cells, action cells, filters & sorts, hooks, theming)](features/table.md) | The `Table` component (`src/components/Table/`) is the **TanStack Table v8** building block behind the [Primary Table templates](primary-table-templates.md). | toga-blox/src/components/Table/index.ts, toga-blox/src/components/Table/types.ts, toga-blox/src/components/Table/utils/buildTanstackColumns.tsx, toga-blox/src/components/Table/utils/resolveCellType.tsx, toga-blox/src/components/Table/components/cellTypes, toga-blox/src/components/Table/components/actionCells, toga-blox/src/components/Table/components/columnFiltersAndSorts, toga-blox/src/components/Table/hooks, toga-blox/src/components/Table/themeConfig |
12
+ | [Table component (cells, action cells, filters & sorts, hooks, theming)](features/table.md) | The `Table` component (`src/components/Table/`) is the **TanStack Table v8** building block behind the [Primary Table templates](primary-table-templates.md). | toga-blox/src/components/Table/hooks/useFetchTablePageMeta.ts, toga-blox/src/api/types.ts, toga-blox/src/components/Table/index.ts, toga-blox/src/components/Table/types.ts, toga-blox/src/components/Table/utils/buildTanstackColumns.tsx, toga-blox/src/components/Table/utils/resolveCellType.tsx, toga-blox/src/components/Table/components/cellTypes, toga-blox/src/components/Table/components/actionCells, toga-blox/src/components/Table/components/columnFiltersAndSorts, toga-blox/src/components/Table/hooks, toga-blox/src/components/Table/themeConfig |
13
13
  | [Talos AI-Assistant Component (launcher + slide-out chat panel)](features/talos-assistant.md) | `Talos` is a **shared, pure-UI** AI-assistant component in `@agilant/toga-blox`: a header launcher button (`TalosLauncher`) plus a slide-out chat panel (`TalosP | toga-blox/src/components/Talos/TalosLauncher.tsx, toga-blox/src/components/Talos/TalosPanel.tsx, toga-blox/src/components/Talos/TalosMessage.tsx, toga-blox/src/components/Talos/types.ts, toga-blox/src/components/Talos/theme.ts, toga-blox/src/components/Talos/helpers.tsx, toga-blox/src/components/Talos/stub.ts, toga-blox/src/components/Talos/Talos.module.css, toga-blox/src/components/Talos/index.ts, toga-blox/src/components/index.ts |
14
14
  | [Dynamic npm Publish Pipeline (branch → channel)](workflows/dynamic-publish-pipeline.md) | How `@agilant/toga-blox` (checkout folder `toga-blox-npm`, registry repo key `toga-blox`) publishes a per-environment npm **channel** (dist-tag) from a `_<mode> | toga-blox/.github/workflows/publish.yml, toga-blox/package.json, toga-blox/src/utils/getFontAwesomeIcon.tsx |
@@ -6,9 +6,11 @@ project: TOGa Blox
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-17
9
+ updated: 2026-08-25
10
10
  owners: [apeterson]
11
11
  files:
12
+ - toga-blox/src/components/Table/hooks/useFetchTablePageMeta.ts
13
+ - toga-blox/src/api/types.ts
12
14
  - toga-blox/src/components/Table/index.ts
13
15
  - toga-blox/src/components/Table/types.ts
14
16
  - toga-blox/src/components/Table/utils/buildTanstackColumns.tsx
@@ -123,7 +125,7 @@ Per-column header controls, each a popover trigger with `data-active` / `data-op
123
125
  | `useInfiniteTableData` | `useInfiniteQuery` + IntersectionObserver on a `sentinelRef` |
124
126
  | `useTableInfiniteScroll` | virtualizer-based "near end" trigger (lower-level alternative) |
125
127
  | `useTableEdit` | dirty-row tracking, dot-notation patch, `handleSave` calls `mutationFn` and updates the query cache, clears dirty + exits edit mode on success |
126
- | `useFetchTablePageMeta` | fetches table meta + fans out filter-option queries per field |
128
+ | `useFetchTablePageMeta` | fetches table meta; fans out a filter-option query **only** for fields the server did not already resolve |
127
129
  | `useAssignTableFieldLabels` | merges page-level labels into field defs |
128
130
  | `useTableFieldFilterOptions` | filter options for a single field |
129
131
 
@@ -136,6 +138,19 @@ Per-column header controls, each a popover trigger with `data-active` / `data-op
136
138
  (modal: fixed heights, virtualization disabled). Design tokens are `--primaryTable-*` in
137
139
  `toga.module.css`.
138
140
 
141
+ ## Server-resolved filter options (`filterOptions` in table meta)
142
+
143
+ `useFetchTablePageMeta` **prefers server-supplied `filterOptions`** and skips the per-field
144
+ `recordRoute` fetch for those fields. A single `needsFetchedOptions` predicate drives **both** the
145
+ query list and the index lookup, so the two can never skew (they previously could). `types.ts`:
146
+ `filterOptions[].uuid` is optional, and `surfaceSlug` is declared on `TableViewField`.
147
+
148
+ This exists because an unscoped `GET /{recordRoute}` returns *every* row of a lookup table, which is
149
+ wrong whenever the offered set is per-client. The backend now resolves the set through the Surface
150
+ layer and emits it — see
151
+ [sales-order-status-filter-surface](../../_underscore/features/sales-order-status-filter-surface.md).
152
+ **The blox side never derives a surface slug by naming convention; it renders what meta gives it.**
153
+
139
154
  ## Gotchas
140
155
 
141
156
  - `MIN_COLUMN_WIDTH = 120` is the shrink floor only for columns with **no** explicit width.
@@ -183,6 +198,12 @@ Per-column header controls, each a popover trigger with `data-active` / `data-op
183
198
  requires `npm run build` before it takes effect at runtime.
184
199
 
185
200
  ## Change history
201
+ - 2026-08-25 — `useFetchTablePageMeta` now prefers **server-supplied `filterOptions`** and skips the
202
+ `recordRoute` fetch for those fields (query list + index lookup share one `needsFetchedOptions`
203
+ predicate so they can't skew); `filterOptions[].uuid` made optional and `surfaceSlug` declared on
204
+ `TableViewField`. Implements a TODO the file already carried; first consumer is the per-client
205
+ sales-order status filter. Reminder: blox `dist/` is gitignored — `npm run build` before consumers
206
+ see it. (commit `dded92a6`) (apeterson)
186
207
  - 2026-08-17 — Built multi-chip text column filter: each `HeaderFilterSearch` search is now its
187
208
  own `TextFilterTag {value, mode}`, the mode dropdown sets the mode for the next chip, existing
188
209
  chips keep their mode, and all chips AND together. Persisted as a single readable URL param
@@ -18,7 +18,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
18
18
 
19
19
  ## 2.0 framework
20
20
 
21
- - **_underscore** (_Underscore) _(framework core)_ — 61 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
+ - **_underscore** (_Underscore) _(framework core)_ — 62 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 55 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 25 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 8 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -6,6 +6,7 @@ apps:
6
6
  - api2
7
7
  - toga2-supply
8
8
  - toga25-supply
9
+ - toga-blox
9
10
  - toga2-commerce
10
11
  - worker2
11
12
  - worker
@@ -14,7 +15,7 @@ project: _Underscore
14
15
  client: quad
15
16
  type: profile
16
17
  status: active
17
- updated: 2026-08-18
18
+ updated: 2026-08-25
18
19
  owners: ["jcardinal", "bala", "apeterson", "ajean"]
19
20
  files: []
20
21
  related:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.643",
3
+ "version": "1.0.645",
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",