toga-ai 1.0.556 → 1.0.557

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.
@@ -20,6 +20,7 @@
20
20
  | [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 |
21
21
  | [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, 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 |
22
22
  | [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 |
23
+ | [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 |
23
24
  | [Forecast.Sales NetSuite import engine (real-time webhook)](features/forecast-sale-import.md) | Real-time importer that takes a NetSuite **sale** record and writes its lines into `Forecast.Sales` (the Forecast2 revenue table). | worker2/Component/Forecast/SaleImport/SaleImport.php, worker2/Component/Forecast/Db/Db.php, _underscore/Component/Api/Netsuite/Netsuite.php, worker2/Worker/Netsuite/Invoice.php, worker2/Worker/Netsuite/CashSale.php, worker2/Worker/Netsuite/CreditMemo.php, worker2/Worker/Netsuite/CashRefund.php, worker2/Worker/Netsuite/JournalEntry.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Netsuite/SalesOrder.php, dbchanges2/Forecast/2026-06-26a - Add journalEntry to Sales transaction type enum.sql, test/@dave/test_invoice_lifecycle.php, test/@dave/test_je_lifecycle.php, test/@dave/test_creditmemo_lifecycle.php, test/@dave/test_cashsale_lifecycle.php, test/@dave/test_cashrefund_lifecycle.php, test/@dave/test_fetchrecord_routes.php, test/@dave/verify_je_classification.php, test/@dave/probe_je_accounts.php, test/@dave/probe_je_shape.php, test/@dave/fixer.php, test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/Junk Drawer/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js |
24
25
  | [isFulfillable Propagation Up the SO↔PO Chain](features/fulfillable-item-propagation.md) | `Items.isFulfillable` is a boolean that gates whether a storefront line's **Qty Fulfilled** cell is actionable. | _underscore/Model/Client/Item.php, _underscore/Model/Compass/Item.php, dbchanges2/Core/2026-07-17 - Items isFulfillable RecordField.sql, dbchanges2/Core/2026-07-17 - RegisterItemIsFulfillableInterceptors.sql, dbchanges2/Client/2026-07-17 - ItemsisFulfillable.sql |
25
26
  | [Item-Fulfillment Stage Lifecycle (picked/packed/shipped) & Order Status](features/item-fulfillment-stage-lifecycle-and-order-status.md) | Every ItemFulfillment (IF) now carries an explicit **stage** — picked → packed → shipped — resolved through `ItemFulfillmentStages → ItemFulfillmentStatuses` (m | _underscore/Model/Client/SalesOrder.php, _underscore/Model/Quad/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderStatus.php, _underscore/Model/Client/SalesOrderItem.php, _underscore/Model/Client/Item.php, _underscore/Model/Client/PurchaseOrderItem.php, library/app/api/toga2.php, dbchanges2/Client/2026-06-30a - BackfillNullStageItemFulfillmentsToShipped.sql, dbchanges2/Client/2026-06-30b - SalesOrderStatusesPickedPacked.sql, dbchanges2/Client/2026-06-30c - ItemFulfillmentStageIdNotNull.sql, dbchanges2/Client_CompassCanada/2026-06-30a - ItemFulfillmentLifecycleAndShippedBackfill.sql |
@@ -28,7 +29,7 @@
28
29
  | [_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 |
29
30
  | [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 |
30
31
  | [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 |
31
- | [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 |
32
+ | [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 |
32
33
  | [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/Query.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php, api2/Controller/Index.php |
33
34
  | [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 |
34
35
  | [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 |
@@ -7,7 +7,7 @@ client: shared
7
7
  type: feature
8
8
  status: active
9
9
  updated: 2026-08-11
10
- owners: [mhammontree]
10
+ owners: [mhammontree, tcox]
11
11
  files:
12
12
  - test/@Mark/true-80824-fedex-inflate-test.php
13
13
  - dbchanges2/Client_Growrk/2026-08-10e - GrowrkUpsServiceMethodCodes.sql
@@ -27,6 +27,7 @@ files:
27
27
  - _underscore/Cloud.php
28
28
  related:
29
29
  - ../architecture.md
30
+ - field-storage-row-hydration.md
30
31
  - recursive-item-fulfillments.md
31
32
  - ../../toga2-supply/features/fulfill-and-ship.md
32
33
  - ../../api2/features/record-scripts.md
@@ -276,6 +277,12 @@ review. **The UPS client has the same latent gap.**
276
277
 
277
278
  ## `FIELD_STORAGE` mechanics (reference)
278
279
 
280
+ > **⚠ `TrackingNumbers.labelPdfFile` has NO physical column in any `Client_*` schema in any
281
+ > environment** (verified 2026-08-11) — the 2024 change registered the Core `RecordField` but never
282
+ > wrote an `ALTER TABLE`. Because storage fields hydrate **lazily per returned row**, `GET
283
+ > /tracking-numbers` 500s (`EO-1` / MySQL 1054) the moment a query matches rows. Full write-up +
284
+ > fix: [FIELD_STORAGE row hydration](field-storage-row-hydration.md).
285
+
279
286
  `Model.php` handles storage fields separately from regular columns (write loop ~line
280
287
  393; read ~line 730). Config keys in `[_underscore]`: `model_storage_folder` → local
281
288
  folder mode (if set, S3 is never used); `model_storage_s3_bucket` +
@@ -416,6 +423,11 @@ still discards the unsaved in-memory value.
416
423
  bill) — expect a hard error rather than a silently-wrong bill in those cases.
417
424
 
418
425
  ## Change history
426
+ - 2026-08-11 — Flagged that `TrackingNumbers.labelPdfFile` has **no physical column in any
427
+ `Client_*` schema in any environment** (only the Core `RecordFields` metadata was ever inserted),
428
+ so `GET /tracking-numbers` 500s once a query returns rows — storage fields hydrate lazily per row,
429
+ which is why it stayed dormant. Details in the new
430
+ [FIELD_STORAGE row hydration](field-storage-row-hydration.md) doc. (tcox)
419
431
  - 2026-08-11 — TRUE-80824: **made FedEx carrier rejection reasons readable.** FedEx returned a
420
432
  **gzip-encoded HTTP 400** and `_ApiRequest` sets no `CURLOPT_ENCODING`, so `json_decode` saw
421
433
  compressed bytes and `describeShipmentError()` printed `\x1f\x8b…`; with `setLogging(false)` on the
@@ -0,0 +1,105 @@
1
+ ---
2
+ title: "FIELD_STORAGE fields — per-row lazy hydration and the platform-wide missing-column 500"
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-11
10
+ owners: [tcox]
11
+ files:
12
+ - _underscore/Model.php
13
+ - _underscore/Model/Client/TrackingNumber.php
14
+ - _underscore/Model/Client/Invoice.php
15
+ - dbchanges2/Core/HISTORIC/2024/2024-11b - item-fulfillments.sql
16
+ related:
17
+ - carrier-shipping-labels.md
18
+ - ../../api2/features/v2-api-error-codes.md
19
+ - ../../dbchanges2/workflows/client-schema-drift-audit.md
20
+ ---
21
+
22
+ ## Summary
23
+
24
+ `FIELD_STORAGE` is the 2.0 field type for blob-backed columns (S3 or local folder). Unlike every
25
+ other declared field it is **excluded from the model's main SELECT** (`Model.php` ~L664) and
26
+ **hydrated lazily, per returned row**, when the V2 engine walks the result set. That single
27
+ difference changes the failure mode of schema drift completely:
28
+
29
+ > **A `FIELD_STORAGE` field whose column does not exist cannot fail on an empty result set.**
30
+ > The request only 500s once a query actually **returns rows** — so the defect sits dormant for
31
+ > months and then appears the day a client's data starts matching.
32
+
33
+ **Live platform defect (found 2026-08-11, unfixed):** `TrackingNumbers.labelPdfFile` and
34
+ `Invoices.invoicePdfFile` are declared `FIELD_STORAGE` on the models but the columns exist in
35
+ **no `Client_*` schema in any environment** — verified against `information_schema` on both
36
+ dev-sandbox and production. `GET /tracking-numbers` and `GET /invoices` 500 for **every client**
37
+ as soon as a row matches.
38
+
39
+ ## How storage fields differ from normal fields
40
+
41
+ | | Normal / `c_` field | `FIELD_STORAGE` field |
42
+ |---|---|---|
43
+ | In the model's SELECT | **Yes** — `_Model` builds the SELECT from every declared property, ignoring the caller's `fields=` | **No** — skipped at `Model.php` ~L664 |
44
+ | When the value is read | With the row | **Lazily, per row**, at depth 1 of hydration (`Model.php` ~L730) |
45
+ | Missing column, 0 rows returned | **500** (`EO-1` / MySQL 1054) — the SELECT itself is invalid | **200** — nothing is hydrated, nothing fails |
46
+ | Missing column, ≥1 row returned | 500 | **500** (`EO-1` / MySQL 1054 "Unknown column") |
47
+
48
+ The consequence for diagnosis: the usual
49
+ [client schema-drift audit](../../dbchanges2/workflows/client-schema-drift-audit.md) reasoning
50
+ ("a missing column fails **any** load of the record, on **any** route") is **not** true for storage
51
+ fields. An endpoint that returns 200 on an empty/filtered query is **not** evidence its schema is
52
+ sound. Re-drive the request with a filter you know matches rows before clearing it.
53
+
54
+ Storage path, config keys and the two 2026-07 `Model.php` write/read bugs are documented in
55
+ [carrier shipping labels → `FIELD_STORAGE` mechanics](carrier-shipping-labels.md).
56
+
57
+ ## The current defect — `labelPdfFile` / `invoicePdfFile` were never migrated
58
+
59
+ - `_Model_Client_TrackingNumber::$labelPdfFile = self::FIELD_STORAGE` was added on **2024-10-16**
60
+ (`ffe72b61`, "Adding the fedex shipment api call").
61
+ - `_Model_Client_Invoice::$invoicePdfFile = self::FIELD_STORAGE` is declared the same way.
62
+ - `dbchanges2/Core/HISTORIC/2024/2024-11b - item-fulfillments.sql` inserted only the **Core
63
+ `RecordFields` metadata** for them. **No `ALTER TABLE` was ever written**, so the physical
64
+ columns were never created in any tenant schema.
65
+ - Confirmed absent from **every** `Client_*` schema on **both** dev-sandbox and production.
66
+
67
+ Dormant for ~10 months because the frontend fulfillment → tracking path was broken in other ways
68
+ and never returned tracking rows. **Elite was the first client to return rows and hit it.**
69
+
70
+ **Backend fix (correct):** a `dbchanges2` migration adding both columns to all `Client_*` schemas —
71
+ or, if the feature is dead, remove the properties from the models. Do not "fix" it per client; the
72
+ declaration is in the shared base model, so every tenant is affected identically.
73
+
74
+ **Sandbox demo unblock only** (hand-applied, not a substitute for the migration):
75
+
76
+ ```sql
77
+ ALTER TABLE TrackingNumbers ADD COLUMN labelPdfFile LONGBLOB NULL;
78
+ ALTER TABLE Invoices ADD COLUMN invoicePdfFile LONGBLOB NULL;
79
+ ```
80
+
81
+ ## Gotchas / known issues
82
+
83
+ - **"It returns 200" proves nothing about a storage field.** Empty result sets never hydrate.
84
+ Reproduce with rows.
85
+ - **The 500 is per-row, so it looks data-dependent.** The same route works for one filter and
86
+ fails for another — which reads like a bad row rather than a missing column.
87
+ - **Metadata without DDL is a real migration state.** A `Core.RecordFields` row can exist for a
88
+ column that was never created; the field registration is what makes the API *offer* it, not what
89
+ makes it *exist*. When auditing drift, check `information_schema` for storage fields too — the
90
+ model-vs-schema diff described in the drift audit will otherwise not flag them as urgent because
91
+ nothing is failing yet.
92
+ - **Blast radius is the platform, not a tenant.** Unlike the usual one-client drift, a field
93
+ declared on a shared `_Model_Client_*` class with no migration anywhere means **all** clients are
94
+ broken at once.
95
+
96
+ ## Change history
97
+ - 2026-08-11 — First capture: documented that `FIELD_STORAGE` fields are excluded from the model's
98
+ main SELECT and hydrated **lazily per returned row**, so a missing storage column 500s only when
99
+ a query actually returns rows (empty sets stay 200). Recorded the live platform defect —
100
+ `TrackingNumbers.labelPdfFile` (declared 2024-10-16, `ffe72b61`) and `Invoices.invoicePdfFile`
101
+ exist in **no `Client_*` schema in any environment**, because the 2024-11b item-fulfillments
102
+ change inserted only the Core `RecordFields` metadata and no `ALTER TABLE`; `GET /tracking-numbers`
103
+ and `GET /invoices` 500 for every client once rows match. Found via Elite, the first client to
104
+ return tracking rows. Fix = a dbchanges2 migration across all `Client_*` schemas (or drop the
105
+ model fields). (tcox)
@@ -6,12 +6,14 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-10
9
+ updated: 2026-08-11
10
10
  owners: [tcox]
11
11
  files:
12
12
  - _underscore/Model/Core/Page.php
13
+ - _underscore/Model/Client/TableView.php
13
14
  related:
14
15
  - surface-resolver.md
16
+ - ../../api2/features/tableview-field-metadata.md
15
17
  - error-reporting-issue-event.md
16
18
  - ../../../clients/elite/features/supply2-tableview-config-drift.md
17
19
  ---
@@ -33,6 +35,42 @@ bug that takes down *unrelated* pages for any client that uses the feature.
33
35
  Example (Elite): row id 1 relabels field **58** to **"Total"** in the context of field **190**
34
36
  (`ServiceRequests.id`) — authored for the `service-requests` view.
35
37
 
38
+ ## Why this matters: table-view **column headers** for joined columns need context rows
39
+
40
+ This is the mechanism behind "why is my column header a camelCase slug?" — and the answer is
41
+ counter-intuitive enough that it has cost more than one session.
42
+
43
+ A table view's column header is resolved by the frontend (`PrimaryTable` in `toga2-supply`) out of
44
+ the **page meta**, by two *different* paths depending on where the column's field comes from:
45
+
46
+ | Column's field | Lookup path in `pageMeta.settings.fields` |
47
+ |---|---|
48
+ | on the view's **base record** | `[record][field][label-singular]` — a flat, context-free row |
49
+ | reached through a **join** | `[record][field][contextRecord][contextField][label-singular]` |
50
+
51
+ So for a **joined** column, a `ClientRecordFieldSettings` row with a null `contextRecordFieldId`
52
+ lands in the flat path the frontend never reads for that column, and the label silently does
53
+ nothing. The row must carry **`contextRecordFieldId` = the table-view join's
54
+ `parentRecordFieldId`** (`Model/Client/TableView.php` sets `settings.contextTable` /
55
+ `settings.contextField` for every joined column from exactly that value).
56
+
57
+ **When nothing resolves, the header falls back to the raw `TableViewFields.slug`** — which is why
58
+ unlabeled joined columns render as camelCase identifiers rather than as blanks.
59
+
60
+ ### The `Core.PageRecords` gate
61
+
62
+ `Page.php` assembles `settings.fields` from an **INNER JOIN** on `Core.RecordFields` (~L652) that is
63
+ gated by **`Core.PageRecords`**: only fields of records *attached to the page* are emitted at all.
64
+ A perfectly correct settings row therefore does nothing until **both** the labelled field's record
65
+ **and** the context record are attached to the page. Sequencing for any such change:
66
+
67
+ 1. **The `isset()` guard in `Page.php` (below) goes first.** Until it ships, adding context rows is
68
+ actively dangerous — a context row fatals **every** page that contains the labelled field's
69
+ record but not the context record.
70
+ 2. Then the `Core.PageRecords` attachments.
71
+ 3. Then the `ClientRecordFieldSettings` rows, as a `dbchanges2` migration (hand-inserts on beta do
72
+ not survive a tenant refresh).
73
+
36
74
  ## The bug — "Undefined array key \<contextRecordFieldId\>" 500
37
75
 
38
76
  > **Status: reported to the backend, unfixed as of 2026-08-10.**
@@ -68,6 +106,17 @@ data change, is what makes it fatal.
68
106
  customization. The guard belongs in the framework.
69
107
 
70
108
  ## Change history
109
+ - 2026-08-11 — Documented what context-scoped settings are actually **for**: table-view **column
110
+ header labels**. A base-record column resolves as `settings.fields[record][field][label-singular]`
111
+ but a **joined** column resolves the context-nested
112
+ `settings.fields[record][field][contextRecord][contextField][label-singular]`, so a label row for a
113
+ joined column must carry `contextRecordFieldId` = the join's `parentRecordFieldId`
114
+ (`Model/Client/TableView.php` emits `settings.contextTable`/`contextField` from it) — a
115
+ context-free row lands in a path the frontend never reads. Unresolved headers fall back to the raw
116
+ `TableViewFields.slug` (the camelCase headers). Also recorded the **`Core.PageRecords` gate**
117
+ (`Page.php` ~L652 INNER JOINs `Core.RecordFields` only for records attached to the page) and the
118
+ mandatory ordering: `isset()` guard → PageRecords attachments → settings rows as a migration.
119
+ (tcox)
71
120
  - 2026-08-10 — First capture: documented context-scoped `ClientRecordFieldSettings` and the
72
121
  `Page.php:824` *"Undefined array key \<contextRecordFieldId\>"* fatal (context field not on the
73
122
  requested page), which the 2026-08-07 strict Sentry handler turned from a warning into a 500 on
@@ -19,7 +19,7 @@
19
19
  | [TableView row-filtering via apiWhereClause (options.where grammar, end to end)](features/tableview-apiwhereclause-row-filtering.md) | `TableViews.apiWhereClause` (TEXT, nullable) is the sanctioned, code-free way to restrict or exclude rows from a 2.0 table view. | api2/Component/Api/V2/V2.php, _underscore/Model/Client/TableView.php, toga2-supply/src/api/toga.ts |
20
20
  | [TableView field/column metadata (TableViewFields, hidden projected columns)](features/tableview-field-metadata.md) | The columns of a 2.0 table view are defined by DB metadata, not code. | _underscore/Model/Client/TableView.php, api2/Component/Api/V2/V2.php, dbchanges2/Client/2026-07-20 - ItemsUuidForPurchaseOrderItemsTableView.sql |
21
21
  | [Tickets API (/v2/tickets)](features/tickets-api.md) | The generic ticket endpoint of the 2.0 REST API. | Component/Api/V2/V2.php |
22
- | [V2 API error/message codes (EV/EZ troubleshooting map)](features/v2-api-error-codes.md) | The V2 JSON engine (`Component/Api/V2/V2.php`) returns short **message codes** in the response `error` field, grouped by family: `EN-*` authentication, `EZ-*` a | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, api2/Component/Api/V2/Response/Oauth/Oauth.php, api2/Controller/Index.php, toga2-supply/src/globalTypes.ts, toga2-supply/src/pages/Orders/api/OrdersApi.ts, _underscore/Model/Client/TrackingNumber.php |
22
+ | [V2 API error/message codes (EV/EZ troubleshooting map)](features/v2-api-error-codes.md) | The V2 JSON engine (`Component/Api/V2/V2.php`) returns short **message codes** in the response `error` field, grouped by family: `EN-*` authentication, `EZ-*` a | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, api2/Component/Api/V2/Response/Oauth/Oauth.php, api2/Controller/Index.php, toga2-supply/src/globalTypes.ts, toga2-supply/src/pages/Orders/api/OrdersApi.ts, toga2-supply/src/api/toga.ts, _underscore/Model/Client/TrackingNumber.php |
23
23
  | [V2 REST query contract (params, where grammar, encoding, ACL behavior)](features/v2-rest-query-contract.md) | What an **HTTP client** has to get right to query the Toga v2 REST API: which query params are recognized, the exact `where` grammar, how the query string is (n | api2/Component/Api/V2/V2.php |
24
24
  | [V2 reverse hasMany collections must be named in the fetch fields whitelist](features/v2-reverse-hasmany-fields-whitelist.md) | In the V2 JSON engine, a **reverse hasMany** relationship — the collection of child records that foreign-key back to a parent (e.g. | api2/Component/Api/V2/V2.php |
25
25
  | [AWS CodePipeline Deployment via CodeConnections (GitHub → Elastic Beanstalk)](workflows/codepipeline-codeconnections-deploy.md) | 2.0 apps (`api2`, `_underscore`) are deployed through **AWS CodePipeline**. | api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, api2/ebs/register_instance_to_shared_application_load_balancer.php, api2/.platform/hooks/prebuild/git.sh, api2/.ebextensions/git.php, api2/.ebextensions/git.sandbox-dev.json |
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-10
9
+ updated: 2026-08-11
10
10
  owners: [mhammontree, tcox, jcardinal, ajean, bala]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -15,8 +15,10 @@ files:
15
15
  - api2/Controller/Index.php
16
16
  - toga2-supply/src/globalTypes.ts
17
17
  - toga2-supply/src/pages/Orders/api/OrdersApi.ts
18
+ - toga2-supply/src/api/toga.ts
18
19
  - _underscore/Model/Client/TrackingNumber.php
19
20
  related:
21
+ - ../../_underscore/features/field-storage-row-hydration.md
20
22
  - ../../_underscore/features/error-reporting-issue-event.md
21
23
  - ../../_underscore/features/acl-permission-chain.md
22
24
  - ../../dbchanges2/workflows/client-schema-drift-audit.md
@@ -46,6 +48,7 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
46
48
  | **EO-1** | Operation failed — identifier "There is no field called 'X' in the '_Model_Client_Y' model" | The DB column **and** `Core.RecordFields` exist, but the **generated model class** `_underscore/Model/Client/<Name>.php` doesn't declare the field. This is the **4th** requirement beyond the 3-file migration — and most often it's a **cross-repo git branch mismatch** (`_underscore` on a branch whose generated model lacks a field the DB/RecordFields already carry) | Declare the field in the generated model class (`public $field = self::FIELD_*`) and put all related repos (`_underscore`, `api2`, `dbchanges2`, `toga2-supply`) on the **same** feature branch — see the ACL doc's writable-field recipe |
47
49
  | **EO-1** | Operation failed — **surfaced from a PHP warning/notice, not a real op error** (e.g. "Attempt to read property 'id' on bool", undefined variable) | A latent PHP warning escalates to a 500 because **Sentry's `ErrorHandler` in api2 promotes warnings/notices into thrown exceptions** (see diagnosis note 5). The known instance: in `V2.php::processRoutePairs()` an **unresolved route** leaves the local `$record = false`, and the post-processing payload interceptor layer then dereferenced `$record->id` → warning → 500 | Guard before dereferencing an unresolved record. The fix added a guard clause `if (!$record) return [$rawRequestedRouteName => $outData];` **before** the interceptor/logging layer (so a bad route returns a clean envelope, not a fatal), and initializes `$record = false;` at the top of the `foreach ($lookupByRouteNames ...)` loop so the invalid-HTTP-method / null-`$action` branch can't leave `$record` undefined (an undefined-variable warning would itself escalate to a 500) |
48
50
  | **EO-1** | Operation failed — MySQL **1054 "Unknown column 'X' in 'field list'"** | The **exact reverse** of the row above: the generated model (or a **trait** it `use`s) *declares* the field but the client table has **no such column**. `_Model` builds its SELECT from **every declared property**, so this fires on **any** load of that record on **any** route — including when the caller never asked for that field. Usual cause: a client DB whose module migration only **partially** applied | Add the missing column to that client DB — plus its `CustomRecordFields` row and `AclCustomFieldPermissions` grant if it is a `c_` field. Audit the rest of the drift first: [client schema-drift audit](../../dbchanges2/workflows/client-schema-drift-audit.md) |
51
+ | **EO-2** | *"invalid configuration / A required database could not be reached while initializing the request"* — **almost always a lie** | The `catch (Throwable $dbBootstrapError)` in `api2/Controller/Index.php` (~L364) wraps the **entire request dispatch**, not just the DB bootstrap, so **any** Throwable that escapes query processing is relabelled EO-2. Real causes seen: MySQL 1054 on a missing column, a promoted PHP warning, a model fatal | **Ignore the message text.** Get the real exception from Sentry (`captureException`) or, on newer builds, from `identifiers.message` in the envelope; then resolve `error.id` per diagnosis note 9 |
49
52
  | **EZ-1** | Unauthorized record/script dispatch | Missing **`AclRecordScripts`** (scripted APIs) or the **`AclRecordPermissions`** four-table chain (records) for the caller's role | Grant `AclRecordScripts` (scripts) or complete the record-CRUD chain |
50
53
  | **EZ-2** | Field-level authorization denied (READ) | The field is registered (`Core.RecordFields` present → no `EV-8`) but the caller's role has no **`AclFieldPermissions`** grant to **read** it. The read-side counterpart of `EV-9` (write). **This applies to the `id` field too:** fetching a record by its numeric `id` (`GET /v2/<record>?fields=id,...`) 403s `EZ-2` if `id` has no read grant — the `id` field is ACL-gated like any other, not implicitly readable | Add the `AclFieldPermissions` row for the role (`isWritable=0` if the field is server-written). For a **custom** `c_` field use `AclCustomFieldPermissions` instead — see the ACL doc's standard-vs-custom table |
51
54
  | **EV-5** | Duplicate `transactionId` | The globally-unique `transactionId` was reused | Send a fresh unique `transactionId` per request |
@@ -158,6 +161,21 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
158
161
  **missing DB column**. And several near-identical 500s sharing **one `transactionId`** are
159
162
  apiproxy's cross-region retry, not repeated user impact.
160
163
 
164
+ 13. **`EO-2` is a catch-all mislabel — and an ABSENT log row is itself a signal.** Three facts that
165
+ together cost a full session of misdirected DB-connectivity debugging:
166
+ - The controller's bootstrap `catch (Throwable …)` spans the whole dispatch, so a query-time
167
+ fatal is reported as *"A required database could not be reached."* **The database was fine.**
168
+ Treat EO-2 as "an unclassified Throwable escaped", nothing more.
169
+ - **5xx responses are NOT written to `Logs_<Client>.Api`** — response logging is skipped on a
170
+ fatal. So a request you watched 500 in DevTools with **no matching `Logs_<Client>.Api` row**
171
+ is *confirmation* of a server-side fatal, not a sign you queried the wrong client/timestamp.
172
+ (Pair with note 12: `Logs.Api.transactionId` stores a timestamp, so you cannot look the
173
+ request up by the envelope's `transactionId` either.)
174
+ - **The frontend swallows it.** `togaApiRequest` (`toga2-supply/src/api/toga.ts`) returns
175
+ **`null`** for any response `>= 500` (axios `validateStatus` under 500 throws), so callers see
176
+ a null payload, not an error — every consumer must null-guard, and "the page rendered empty"
177
+ is a plausible symptom of a 500 you never saw.
178
+
161
179
  ## BREAKING (2026-07-30): `error` is now an object, not a bare string
162
180
 
163
181
  The V2 envelope's `error` changed from a bare string (`"EO-1"`) to an **object**:
@@ -243,6 +261,16 @@ Full mechanics:
243
261
 
244
262
  ## Change history
245
263
 
264
+ - 2026-08-11 — Added **`EO-2`** to the map as a **catch-all mislabel**: `Controller/Index.php`'s
265
+ `catch (Throwable $dbBootstrapError)` (~L364) wraps the entire dispatch, so any escaped Throwable
266
+ is reported as *"A required database could not be reached while initializing the request"* — the
267
+ text is usually wrong (the observed case was MySQL 1054 on a `FIELD_STORAGE` column that exists in
268
+ no client schema). Added diagnosis note 13: the real exception is in Sentry / `identifiers.message`;
269
+ **5xx responses are never written to `Logs_<Client>.Api`**, so a missing log row for a request that
270
+ visibly 500'd confirms a server fatal; and `togaApiRequest` in toga2-supply returns **`null`** on
271
+ any `>= 500`, so the frontend shows an empty page rather than an error. Linked the new
272
+ [FIELD_STORAGE row hydration](../../_underscore/features/field-storage-row-hydration.md) doc, whose
273
+ lazy per-row hydration is why that 1054 only fires when a query returns rows. (tcox)
246
274
  - 2026-08-10 — Added a **third `EO-1` case — MySQL 1054 "Unknown column"**: the reverse of the
247
275
  known model-declaration gap. When the model (or a **trait** it `use`s) declares a field the client
248
276
  table lacks, `_Model` still SELECTs it — it builds its SELECT from **every declared property**,
@@ -6,7 +6,7 @@ project: TOGa Supply
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-08-10
9
+ updated: 2026-08-11
10
10
  owners: [apeterson, tcox]
11
11
  files:
12
12
  - toga2-supply/src/stores/useHostNameStore.ts
@@ -132,7 +132,14 @@ before wiring the modal. The fix pattern:
132
132
  the host**, so other hosts never issue the extra request;
133
133
  - expect **columns the other record does not have** — see the `isQuote` gotcha below;
134
134
  - expect the join to be **optional**: an unmatched row resolves to `null` and the modal opens empty.
135
- Decide the empty-state treatment with product; do not silently ship a blank modal.
135
+ Decide the empty-state treatment with product; do not silently ship a blank modal. Elite settled
136
+ on an explicit empty state driven off the resolver query's `isFetched`/`isError` → `null`
137
+ details ("No order details are available for this service request") — copy that shape rather than
138
+ leaving a blank panel.
139
+ - **expect the join to be 1:N, and sort.** Elite's ServiceRequests → SalesOrders was assumed 1:1 and
140
+ is not (one SR had two orders), so the resolver returned an arbitrary row and the modal showed a
141
+ different order run to run. **Fetch an ordering key alongside the uuid** (Elite: `number`) and pick
142
+ deterministically. Verify the cardinality in the data — do not take it from the view definition.
136
143
 
137
144
  ## Getting a login for a new host (auth + test user)
138
145
 
@@ -197,7 +204,10 @@ actually talks to.
197
204
  - **Selection is uuid-driven and non-null-asserted.** Selection resolves from the URL param
198
205
  `<tableSlug>:group=<uuid>` with fallback `groupOptions.find(o=>o.isDefault) || groupOptions[0]`;
199
206
  `InventoryPage.tsx` non-null-asserts the uuid find and **throws** if the uuid is missing for a
200
- host. Every host must have a valid default. **There is a third `find(...)!` consumer**:
207
+ host. Every host must have a valid default. (**Fixed 2026-08-11 in `InventoryPage.tsx`**: a URL
208
+ uuid absent from the resolved host's options now falls back to `defaultOption` instead of
209
+ crashing — a shared link from another host no longer white-screens the page.) **There is a third
210
+ `find(...)!` consumer**:
201
211
  `InventorySubTablePage.tsx` (~L139) does the same non-null assert, so a host whose array is
202
212
  missing the selected uuid crashes on the **nested** table too, not just the primary one.
203
213
  - **Shared localStorage is order-dependent and persists across host switches** in one browser
@@ -221,12 +231,26 @@ actually talks to.
221
231
  (callers already fall back to the field name). Reusing another page's `listingSlug` is therefore
222
232
  survivable, but remember it only affects **ACL and search labels** — column headers come from the
223
233
  **table-view** meta, not the page meta.
234
+ - **Fixing a SHARED component for one host: make the condition provably a no-op for the others.**
235
+ The bar is *byte-identical rendering* for every host that does not meet the new condition, and you
236
+ should be able to state why. Worked examples (2026-08-11): `Toaster.tsx` centers a **message-only**
237
+ toaster (`flex-1` + `text-center`) — every other call site passes an action, so the branch is never
238
+ taken; `OrdersPage.tsx` re-syncs `pageTitle`/`tableDisplayData` **after the host store hydrates**,
239
+ which only changes first-render behavior where `fields` was `undefined`. A host conditional is the
240
+ escape hatch; a *condition on the data the component already has* is better and safer.
224
241
  - **A record swap costs you columns.** Elite's `service-requests` view is built on ServiceRequests,
225
242
  which has **no `isQuote` column**, so Orders' unconditional `isQuote: false` filter 500'd with
226
243
  `1054 Unknown column`. Filters that assume the SalesOrders shape must be made conditional on a
227
244
  FIELDS flag (`hasQuoteFilter`), not assumed. Backend confirmed this is an FE-side fix.
228
245
 
229
246
  ## Change history
247
+ - 2026-08-11 — Added the rule for **fixing shared components on one host's behalf** (the condition
248
+ must be a provable no-op for every other host — Toaster message-only centering, OrdersPage state
249
+ re-sync after host-store hydration), recorded that a cross-record resolver join can be **1:N** and
250
+ must sort deterministically on a fetched key (Elite's ServiceRequests → SalesOrders was assumed
251
+ 1:1 and is not), noted the empty-state shape for an unmatched resolver row, and that
252
+ `InventoryPage.tsx` now falls back to `defaultOption` for a URL group uuid the host does not have
253
+ instead of crashing on `find(...)!`. (tcox)
230
254
  - 2026-08-10 — Added **reference implementation 2 — retitling a shared page** (per-host
231
255
  `FIELDS/<HOST>/BASE.json` supplying `pageTitle`/`tableSlug`/`listingSlug`, explicit host entry in
232
256
  the view model, slug literals threaded through `fields?.` with literal fallbacks, feature removal
@@ -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)_ — 52 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
+ - **_underscore** (_Underscore) _(framework core)_ — 53 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 46 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 22 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 8 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -3,6 +3,6 @@
3
3
  | Doc | Framework | Summary | Files |
4
4
  |-----|-----------|---------|-------|
5
5
  | [Elite SalesOrder → NetSuite Push (postPost/postPut interceptors → worker2)](features/salesorder-netsuite-push.md) | 2.0 | Elite orders created in Toga are pushed into NetSuite **event-driven**, not on a cron. | _underscore/Model/Elite/SalesOrder.php, worker2/Worker/Netsuite/SalesOrder.php, dbchanges2/Client_Elite/2026-08-10 - SalesOrderNetsuiteInterceptors.sql |
6
- | [Elite — supply2 frontend scope (Inventory + Service Requests, both built)](features/supply2-scope.md) | 2.0 | Scope for onboarding Elite to the `toga2-supply` frontend (host `ELITE`). | toga2-supply/ELITE-CLIENT-TASK-NOTES.md, toga2-supply/src/pages/Inventory/viewModel/FIELDS/DUMMYGROUPOPTIONS.ts, toga2-supply/src/pages/Inventory/viewModel/FIELDS/INVENTORYPAGEFIELDS.ts, toga2-supply/src/pages/Inventory/viewModel/index.ts, toga2-supply/src/pages/Inventory/listing/InventoryPage.tsx, toga2-supply/src/pages/Inventory/listing/InventoryRouter.tsx, toga2-supply/src/pages/Inventory/listing/InventorySubTablePage.tsx, toga2-supply/src/utils/resolveClientHostName.ts, toga2-supply/src/utils/convertConstructorColumnTitles.ts, toga2-supply/src/pages/Orders/OrdersPage.tsx, toga2-supply/src/pages/Orders/viewModel/FIELDS/ELITE/BASE.json, toga2-supply/src/pages/Orders/viewModel/useOrdersPageViewModel.ts, toga2-supply/src/pages/Orders/api/OrdersApi.ts, toga2-supply/src/pages/Orders/view/OrderView/viewModel/useOrderDetailsViewModel.ts, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/renderModalContent.tsx, toga2-supply/src/components/layout/SlideMenu/SlideMenu.tsx, toga2-supply/package.json |
6
+ | [Elite — supply2 frontend scope (Inventory + Service Requests, both built)](features/supply2-scope.md) | 2.0 | Scope for onboarding Elite to the `toga2-supply` frontend (host `ELITE`). | toga2-supply/ELITE-CLIENT-TASK-NOTES.md, toga2-supply/src/pages/Orders/view/OrderView/viewModel/FIELDS/ELITE/BASEFIELDS.json, toga2-supply/src/pages/Orders/viewModel/FIELDS/ELITE/BASE.json, toga2-supply/src/pages/Inventory/viewModel/FIELDS/DUMMYGROUPOPTIONS.ts, toga2-supply/src/hooks/useFetchData.tsx, toga2-supply/src/components/ui/Toaster.tsx, toga2-supply/src/pages/Inventory/viewModel/FIELDS/DUMMYGROUPOPTIONS.ts, toga2-supply/src/pages/Inventory/viewModel/FIELDS/INVENTORYPAGEFIELDS.ts, toga2-supply/src/pages/Inventory/viewModel/index.ts, toga2-supply/src/pages/Inventory/listing/InventoryPage.tsx, toga2-supply/src/pages/Inventory/listing/InventoryRouter.tsx, toga2-supply/src/pages/Inventory/listing/InventorySubTablePage.tsx, toga2-supply/src/utils/resolveClientHostName.ts, toga2-supply/src/utils/convertConstructorColumnTitles.ts, toga2-supply/src/pages/Orders/OrdersPage.tsx, toga2-supply/src/pages/Orders/viewModel/FIELDS/ELITE/BASE.json, toga2-supply/src/pages/Orders/viewModel/useOrdersPageViewModel.ts, toga2-supply/src/pages/Orders/api/OrdersApi.ts, toga2-supply/src/pages/Orders/view/OrderView/viewModel/useOrderDetailsViewModel.ts, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/renderModalContent.tsx, toga2-supply/src/components/layout/SlideMenu/SlideMenu.tsx, toga2-supply/package.json |
7
7
  | [Elite — stale TableView config (11 dead Core.RecordFields across 9 views)](features/supply2-tableview-config-drift.md) | 2.0 | `Client_Elite`'s `TableViewJoins` predate **two** platform bridge-table migrations and still reference **11 deleted `Core.RecordFields` ids (211, 321, 932, 358, | dbchanges2/Client_Elite/, dbchanges2/Client_Nychh/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql |
8
8
  | [Elite](profile.md) | 2.0 | Elite is a managed-services client that uses **Freshservice** as their helpdesk platform. | worker2/Worker/Elite.php, library/app/api/toga2.php, _underscore/Model/Elite/SalesOrder.php |
@@ -6,10 +6,15 @@ project: TOGa Supply
6
6
  client: elite
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-10
9
+ updated: 2026-08-11
10
10
  owners: [apeterson, tcox]
11
11
  files:
12
12
  - toga2-supply/ELITE-CLIENT-TASK-NOTES.md
13
+ - toga2-supply/src/pages/Orders/view/OrderView/viewModel/FIELDS/ELITE/BASEFIELDS.json
14
+ - toga2-supply/src/pages/Orders/viewModel/FIELDS/ELITE/BASE.json
15
+ - toga2-supply/src/pages/Inventory/viewModel/FIELDS/DUMMYGROUPOPTIONS.ts
16
+ - toga2-supply/src/hooks/useFetchData.tsx
17
+ - toga2-supply/src/components/ui/Toaster.tsx
13
18
  - toga2-supply/src/pages/Inventory/viewModel/FIELDS/DUMMYGROUPOPTIONS.ts
14
19
  - toga2-supply/src/pages/Inventory/viewModel/FIELDS/INVENTORYPAGEFIELDS.ts
15
20
  - toga2-supply/src/pages/Inventory/viewModel/index.ts
@@ -108,14 +113,22 @@ is literal-identical to before.
108
113
 
109
114
  The backend table view (slug supplied by the backend dev) is built on the **ServiceRequests RECORD**
110
115
  — joining Tickets / Customers / ServiceRequestTypes / SalesOrders, where
111
- `SalesOrders.serviceRequestId = ServiceRequests.id` (1:1). It is **not** a renamed SalesOrders view.
116
+ `SalesOrders.serviceRequestId = ServiceRequests.id`. It is **not** a renamed SalesOrders view.
112
117
  Three consequences drove the rest of the work:
113
118
 
119
+ > **⚠ CORRECTION (2026-08-11): the ServiceRequests ↔ SalesOrders relationship is 1:N, not 1:1.**
120
+ > This doc previously said 1:1; that is wrong. **SR100001 links to two sales orders (SA100000 and
121
+ > SA100003).** Any resolver that assumes a single match will pick an arbitrary row and the modal
122
+ > will show a *different* order run to run.
123
+
114
124
  1. **Row uuids are ServiceRequests uuids, but every detail-modal route is a `/sales-orders` record
115
125
  route.** Fixed with `getSalesOrderUuidForServiceRequest()` in `src/pages/Orders/api/OrdersApi.ts`
116
126
  (`GET /sales-orders` + a ServiceRequests join, `where ServiceRequests.uuid = …`), whose resolved
117
127
  `detailsUuid` is threaded through `useOrderDetailsViewModel.ts` via react-query. The query is
118
128
  **enabled only for `hostName === "ELITE"`**, so every other host bypasses it entirely.
129
+ Because the join is **1:N**, the resolver fetches `uuid` **and** `number` and **sorts
130
+ client-side, taking the lowest number** — a deterministic pick, so the modal opens the same order
131
+ every time.
119
132
  2. **ServiceRequests has no `isQuote` column.** `OrdersPage`'s unconditional `isQuote: false` filter
120
133
  500'd with `1054 unknown column`; the backend confirmed the fix belongs on the FE, hence
121
134
  `hasQuoteFilter`.
@@ -126,18 +139,45 @@ Three consequences drove the rest of the work:
126
139
  Verified working end-to-end on the one Elite service request that has a linked sales order
127
140
  (ticket INC-13982).
128
141
 
129
- ## Open question — orphan ServiceRequests (needs backend/product input)
142
+ ## Part 3 — order-details modal & polish (2026-08-11)
143
+
144
+ The details modal is now per-host configured through
145
+ `src/pages/Orders/view/OrderView/viewModel/FIELDS/ELITE/BASEFIELDS.json`:
146
+
147
+ - a **Type** row sourced from `salesOrderType.name`;
148
+ - `settings.currencyCharacter` `"$"`;
149
+ - Elite-worded copy for the no-details state;
150
+ - section visibility flags for the sections Elite does not show.
151
+
152
+ `FIELDS/ELITE/BASE.json` also **disables the export button** for Elite.
153
+
154
+ **Orphan service requests are a DESIGNED EMPTY STATE, not a bug** (this closes the open question
155
+ this doc previously carried). `useOrderDetailsViewModel.ts` reads the resolver's `isFetched` /
156
+ `isError` and yields **`null` `orderDetails`**, which renders
157
+ *"No order details are available for this service request."* Most Elite service requests on
158
+ dev-sandbox have no linked sales order, so this is the common path — do not "fix" the blank modal.
159
+
160
+ **Elite skips the ASN tracking query.** `OrdersApi.ts` short-circuits the
161
+ `AdvanceShippingNoticeUnits` lookup for `ELITE` — **that table does not exist in `Client_Elite`**,
162
+ so issuing it 500s the details fetch.
163
+
164
+ **Inventory nested view renamed to the backend's slug.** `DUMMYGROUPOPTIONS.ts` now uses
165
+ `inventory_units` (the backend's slug) with `nestedPrimaryRecordSlug` `Items`, and
166
+ `useFetchData.tsx`'s primary-fetch filter guard was extended to cover `"Items"`.
130
167
 
131
- **5 of 6** Elite service requests on dev-sandbox have **no linked SalesOrder**, so the details modal
132
- opens empty — the resolver correctly yields `null` and there is nothing to render. Undecided:
168
+ ### Shared-component fixes made along the way (guardrail-safe)
133
169
 
134
- - an explicit "no order linked" empty state, or
135
- - a ServiceRequest-level detail view (not just an order modal), or
136
- - making order-less rows non-clickable.
170
+ Each of these touches a component every host renders, so each was written so that **other hosts are
171
+ byte-identical in behavior** — that is the bar for editing shared UI here (see the
172
+ [host-scoping playbook](../../../2.0/apps/toga2-supply/workflows/client-host-scoping.md)):
137
173
 
138
- The prerequisite answer is a data question: **is an order-less service request a legitimate
139
- lifecycle state**, or is the dev-sandbox data simply incomplete? Do not pick a UI treatment before
140
- that is answered.
174
+ - `components/ui/Toaster.tsx` — a **message-only** toaster now centers (conditional `flex-1` +
175
+ `text-center`); the previous `justify-between` stranded the text at the right edge. The condition
176
+ provably no-ops every other call site (they all pass an action).
177
+ - `pages/Orders/OrdersPage.tsx` — `pageTitle` / `tableDisplayData` state is **re-synced after the
178
+ host store hydrates**; on a first visit those rendered with `fields` still `undefined`.
179
+ - `pages/Inventory/listing/InventoryPage.tsx` — a URL group uuid that is not in the resolved host's
180
+ options now falls back to `defaultOption` instead of crashing on the non-null-asserted `find(...)!`.
141
181
 
142
182
  ## Known limitations / notes
143
183
 
@@ -148,6 +188,19 @@ that is answered.
148
188
  2026-08-07 prod→beta refresh that wiped the hand-built `service-requests` view.
149
189
 
150
190
  ## Change history
191
+ - 2026-08-11 — Order-details modal + service-requests polish: per-host
192
+ `FIELDS/ELITE/BASEFIELDS.json` (Type row from `salesOrderType.name`, `$` currency character,
193
+ Elite-worded no-details copy, section flags), export button disabled in `FIELDS/ELITE/BASE.json`,
194
+ inventory nested view renamed to the backend's `inventory_units` slug
195
+ (`nestedPrimaryRecordSlug: Items`) with `useFetchData.tsx`'s filter guard extended to `"Items"`,
196
+ and the ELITE skip of the ASN tracking query (`AdvanceShippingNoticeUnits` does not exist in
197
+ `Client_Elite`). **CORRECTED: ServiceRequests ↔ SalesOrders is 1:N, not 1:1** (SR100001 →
198
+ SA100000 + SA100003), so the resolver now fetches `uuid`+`number` and picks the lowest number
199
+ deterministically. **Closed the orphan-SR open question** — an order-less SR is a *designed empty
200
+ state* ("No order details are available for this service request") driven by the resolver's
201
+ `isFetched`/`isError` yielding null `orderDetails`. Also landed three guardrail-safe shared-component
202
+ fixes (Toaster message-only centering, OrdersPage state re-sync after host-store hydration,
203
+ InventoryPage URL-uuid fallback to `defaultOption`). (tcox)
151
204
  - 2026-08-10 — **Service Requests shipped and verified** (INC-13982): implemented as the Orders page
152
205
  host-configured, not a new route — new `FIELDS/ELITE/BASE.json` (pageTitle/tableSlug/listingSlug/
153
206
  `hasQuoteFilter`/no `approvalActionOptions`), explicit `ELITE` entry in `useOrdersPageViewModel`,
@@ -6,7 +6,7 @@ project: Database Changes
6
6
  client: elite
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-10
9
+ updated: 2026-08-11
10
10
  owners: [tcox]
11
11
  files:
12
12
  - dbchanges2/Client_Elite/
@@ -112,6 +112,33 @@ note, script, or diff that identifies a join by **row id** is worthless after a
112
112
  by **(`tableViewId`, `joinRecordId`, `joinOnRecordFieldId`)** instead — the id columns in the table
113
113
  above are a snapshot for orientation, not a key.
114
114
 
115
+ ## Service-requests column headers — the context-row mapping (authored, BLOCKED)
116
+
117
+ Elite's `service-requests` view renders several headers as raw camelCase slugs because its columns
118
+ are reached through joins and Elite has no context-scoped label rows for them. The mechanism (why a
119
+ joined column needs `contextRecordFieldId`, and the `Core.PageRecords` gate) is in
120
+ [legacy page meta](../../../2.0/apps/_underscore/features/page-meta-context-field-settings.md).
121
+
122
+ Mapping derived for the view (target `Core.RecordFields` id → **context** `Core.RecordFields` id):
123
+
124
+ | Target field id(s) | Context field id | Context field |
125
+ |---|---|---|
126
+ | 566, 574 | 1595 | `ServiceRequests.ticketId` |
127
+ | 679 | 2087 | `customerId` |
128
+ | 203 | 192 | `serviceRequestTypeId` |
129
+ | 48, 175, 58 | 190 | `ServiceRequests.id` |
130
+ | 28 | 43 | `SalesOrders.locationId` |
131
+ | 2766 | — | flat row (base record — **no** context) |
132
+
133
+ **Blocked on the backend, in this order — do not skip ahead:**
134
+
135
+ 1. The `Page.php` `isset()` guard (~L822–829). Until it ships, inserting these rows fatals every
136
+ page that carries the labelled field's record but not the context record.
137
+ 2. `Core.PageRecords` additions on **`sales-orders-listing`** (Elite reuses it): **Tickets** and
138
+ **Service request types**. Without them the fields are not emitted into the meta at all.
139
+ 3. The `ClientRecordFieldSettings` rows themselves, shipped as a `dbchanges2/Client_Elite/`
140
+ migration — a hand-insert on beta will not survive the next tenant refresh (see above).
141
+
115
142
  ## Gotchas / known issues
116
143
 
117
144
  - **A `Client_*` DB that predates a platform migration fails silently until a user opens the
@@ -123,6 +150,12 @@ above are a snapshot for orientation, not a key.
123
150
  [error-reporting-issue-event](../../../2.0/apps/_underscore/features/error-reporting-issue-event.md).
124
151
 
125
152
  ## Change history
153
+ - 2026-08-11 — Added the authored **context-row mapping for the `service-requests` column headers**
154
+ (target → context `Core.RecordFields` ids; 2766 is flat/base-record) and the mandatory backend
155
+ ordering it is blocked behind: `Page.php` `isset()` guard → `Core.PageRecords` additions on
156
+ `sales-orders-listing` (Tickets + Service request types) → the settings rows as a
157
+ `dbchanges2/Client_Elite/` migration. Unlabelled joined columns currently fall back to the raw
158
+ `TableViewFields.slug`, which is the camelCase headers on that view. (tcox)
126
159
  - 2026-08-10 — Recorded the **2026-08-07 prod→beta rebuild of `Client_Elite`** and its four
127
160
  breakages (wiped hand-built `service-requests` view, empty-password `devteam` user, the `isQuote`
128
161
  `1054`, and the `/pages/meta` 500), plus the rule that **`TableViewJoins` row ids shift on
@@ -12,7 +12,7 @@ project: Worker
12
12
  client: elite
13
13
  type: profile
14
14
  status: active
15
- updated: 2026-08-10
15
+ updated: 2026-08-11
16
16
  owners: [snaredla, apeterson, tcox, bala]
17
17
  files:
18
18
  - worker2/Worker/Elite.php
@@ -42,9 +42,11 @@ dbchanges2 **PR #454** (TRUE-80499, snaredla24, 2026-08-05) added the **netsuite
42
42
 
43
43
  Elite's service-request data model: `ServiceRequests` is the record the `service-requests` table
44
44
  view is built on (joining Tickets / Customers / ServiceRequestTypes / SalesOrders, with
45
- `SalesOrders.serviceRequestId = ServiceRequests.id`, 1:1). **Most Elite service requests on
46
- dev-sandbox have no linked sales order** — whether that is a legitimate lifecycle state is an open
47
- question for backend/product (see supply2-scope).
45
+ `SalesOrders.serviceRequestId = ServiceRequests.id`). **That relationship is 1:N, not 1:1**
46
+ (corrected 2026-08-11 — SR100001 links to both SA100000 and SA100003), so anything resolving an SR
47
+ to "its" sales order must pick deterministically. **Most Elite service requests on dev-sandbox have
48
+ no linked sales order at all**, and that is now treated as a legitimate state: the details modal
49
+ renders a designed "no order details" empty state (see supply2-scope).
48
50
 
49
51
  **Elite orders are pushed to NetSuite event-driven (2026-08).** `_Model_Elite_SalesOrder`'s
50
52
  `postPost`/`postPut` interceptors queue the shared worker2 push. Elite's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.556",
3
+ "version": "1.0.557",
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",