toga-ai 1.0.746 → 1.0.747

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.
@@ -33,6 +33,7 @@
33
33
  | [_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 |
34
34
  | [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/ApiRequest.php, _underscore/Model/Client/Logs/Api.php, _underscore/Trait/Netsuite/SalesOrder.php, worker2/Worker/Netsuite/SalesOrder.php, worker2/Component/Forecast/SaleImport/SaleImport.php, worker2/Worker/Netsuite/Opportunity.php |
35
35
  | [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 |
36
+ | [Note records have NO author — every `*Notes` model's `contactId` is unwritten](features/note-record-authorship.md) | **"Who wrote this note?" is currently unanswerable from TOGa 2.0 data.** Every note model in the framework declares a `contactId` foreign key to `Contacts` — th | _underscore/Model/Client/SalesOrderNote.php, _underscore/Model/Client/PurchaseOrderNote.php, _underscore/Model/Client/TransferOrderNote.php, _underscore/Model/Client/TicketNote.php, _underscore/Model/Client/UnitNote.php, _underscore/Model/Client/ServiceRequestNote.php, _underscore/Model/Client/PartRequestNote.php |
36
37
  | [Outbound third-party API rate budget (_Component_Api_RateBudget) — designed + staged, NOT merged](features/outbound-api-rate-budget.md) | A **shared outbound rate budget for every third-party API** we call from 2.0 (ClickUp, NetSuite, FedEx, UPS, …): one small component plus one `Core` table, keye | _underscore/Component/Api/RateBudget/RateBudget.php, _underscore/Component/Api/Clickup/Clickup.php, _underscore/Route.php, _underscore/Query.php, dbchanges2/Core/2026-09-01a - Add ApiRateBudget table.sql, worker2/Worker/Clickup/Api.php |
37
38
  | [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, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/formatTableData.tsx |
38
39
  | [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 |
@@ -0,0 +1,101 @@
1
+ ---
2
+ title: "Note records have NO author — every `*Notes` model's `contactId` is unwritten"
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-09-02
10
+ owners: [apeterson]
11
+ files:
12
+ - _underscore/Model/Client/SalesOrderNote.php
13
+ - _underscore/Model/Client/PurchaseOrderNote.php
14
+ - _underscore/Model/Client/TransferOrderNote.php
15
+ - _underscore/Model/Client/TicketNote.php
16
+ - _underscore/Model/Client/UnitNote.php
17
+ - _underscore/Model/Client/ServiceRequestNote.php
18
+ - _underscore/Model/Client/PartRequestNote.php
19
+ related:
20
+ - ../../toga-blox/features/add-notes-template.md
21
+ - ../../toga25-supply/features/transfer-orders-page.md
22
+ - ../features/sales-order-denial-reason.md
23
+ - ../../../clients/compass-usa/features/approval-decision-flow.md
24
+ ---
25
+
26
+ ## Summary
27
+
28
+ **"Who wrote this note?" is currently unanswerable from TOGa 2.0 data.**
29
+
30
+ Every note model in the framework declares a `contactId` foreign key to `Contacts` — the field that
31
+ *looks* like note authorship — and **no code anywhere writes it.** Note rows are created with the
32
+ body, a type and a timestamp, and no author. Read that as a schema affordance that was never wired
33
+ up, not as a column you can query.
34
+
35
+ Found 2026-09-01 while building `TransferOrderNote` / `TransferOrderNoteType` as faithful mirrors of
36
+ the purchase-order pair; the gap turned out to be platform-wide, not new.
37
+
38
+ ## The seven models, all the same shape
39
+
40
+ Verified against `_underscore` (`grep contactId Model/Client/*Note.php`) — **all seven** declare it:
41
+
42
+ ```php
43
+ public $contactId = [self::FIELD_FOREIGNKEY, self::FIELDOPT_FOREIGNKEY_MODEL => '\_Model_Client_Contact'];
44
+ ```
45
+
46
+ | Model | Table |
47
+ |---|---|
48
+ | `_Model_Client_SalesOrderNote` | `SalesOrderNotes` |
49
+ | `_Model_Client_PurchaseOrderNote` | `PurchaseOrderNotes` |
50
+ | `_Model_Client_TransferOrderNote` | `TransferOrderNotes` |
51
+ | `_Model_Client_TicketNote` | `TicketNotes` |
52
+ | `_Model_Client_UnitNote` | `UnitNotes` |
53
+ | `_Model_Client_ServiceRequestNote` | `ServiceRequestNotes` |
54
+ | `_Model_Client_PartRequestNote` | `PartRequestNotes` |
55
+
56
+ The rest of the shape is uniform too: `<parent>Id`, `<parent>NoteTypeId`, `dtStamp`
57
+ (`FIELD_DATETIME_CREATED`), `note`. So `dtStamp` tells you **when**, the type tells you **what kind**
58
+ — and nothing tells you **who**.
59
+
60
+ ## The consequence, and the workaround that exists in production
61
+
62
+ Because the column is empty, any "submitted by" display has to recover the author from the note
63
+ **body text**. Compass does exactly that: it **regex-scrapes email addresses out of the note body**.
64
+ That works only because their notes happen to contain an address; it is not a pattern to copy, and it
65
+ fails silently on any note that does not.
66
+
67
+ So before building anything that shows or filters by note author:
68
+
69
+ - **Do not assume `contactId` is populated** — check the actual rows for the tenant first. It will be
70
+ NULL.
71
+ - **Do not add a per-tenant body-text scrape.** That is the existing workaround, and it is the reason
72
+ this gap has stayed invisible.
73
+ - **The real fix is to write `contactId` on create**, from the authenticated identity, in whichever
74
+ layer creates the note. That is unclaimed platform work — it also needs a backfill decision for the
75
+ existing rows, which cannot be recovered except by scraping.
76
+
77
+ ## Why a transfer's Notes box does not use these tables at all
78
+
79
+ The toga25-supply Create Transfer Order modal deliberately writes **`TransferOrders.description`**
80
+ (one `varchar(255)` free-text field) instead of note rows: a transfer carries one description, not a
81
+ thread of typed notes from different authors. The authorship gap documented here is part of why —
82
+ a note thread whose entries have no author is a weaker record than a single description field. The
83
+ `TransferOrderNotes` tables and their Core metadata were built anyway and are currently **unused**;
84
+ see [Transfer Orders page](../../toga25-supply/features/transfer-orders-page.md).
85
+
86
+ ## Gotchas
87
+
88
+ - **`contactId` on a `*Notes` model is an unwritten FK — treat it as always NULL.** Seven models,
89
+ zero writers.
90
+ - **A notes list UI has no author to show.** Design for it, or the field silently renders blank for
91
+ every row.
92
+ - **`dtStamp` is `FIELD_DATETIME_CREATED`**, so it is stamped by the ORM on insert and is the only
93
+ provenance a note actually carries.
94
+
95
+ ## Change history
96
+ - 2026-09-02 — First capture. Recorded that **all seven** `*Notes` models (`SalesOrderNote`,
97
+ `PurchaseOrderNote`, `TransferOrderNote`, `TicketNote`, `UnitNote`, `ServiceRequestNote`,
98
+ `PartRequestNote`) declare a `contactId` FK to `Contacts` that **no code writes**, so note
99
+ authorship is unrecoverable from the data; Compass works around it by regex-scraping email
100
+ addresses out of the note body. Surfaced while building the (now unused) `TransferOrderNote` /
101
+ `TransferOrderNoteType` pair for NYCHH transfer orders. (apeterson)
@@ -143,6 +143,21 @@ GET /v2/transfer-orders/{uuid}/transfer-order-items
143
143
  - **The JSON key of an inherent child is the camelCase of the child ROUTE**, not of the table or the
144
144
  model (`V2.php` ~L6310, via `_String::CASE_KEBAB` → `CASE_CAMEL`). `transfer-order-items` →
145
145
  `transferOrderItems`.
146
+ - **🚨 The same route-derived key governs the WRITE path — so the ROUTE is the payload contract.**
147
+ `V2.php:3248` builds `$lookupInherentChildByFieldName` by converting each inherent child's `route`
148
+ kebab→camel, and the **CREATE** branch reuses that *same* lookup at `V2.php:5241`
149
+ (`$childrenToCreate[$key] = $val`). So a nested collection in a `POST` body must be keyed by the
150
+ child **route** in camelCase — not the table, not the model, not the FE's preferred name. Two
151
+ consequences: (a) **renaming a `Core.Records.route` silently renames the payload key of every
152
+ writer**, and nothing in the database enforces or warns about it; (b) a key that matches nothing
153
+ falls through to `$unknownFields` (`V2.php:5251`). Worked example: the toga25-supply Create
154
+ Transfer Order modal posts header + lines in one request as `transferOrderItems` — see
155
+ [Transfer Orders page](../../toga25-supply/features/transfer-orders-page.md).
156
+ > ⚠ **Do not assume the `$unknownFields` error protects you.** It is a *loud* rejection on this
157
+ > branch, but a wrong-shaped nested collection has been measured returning **201 with the data
158
+ > silently dropped** on other paths — see
159
+ > [nested-relationship writes](nested-relationship-writes.md). Match the route exactly and
160
+ > **assert child row counts**; never take a 2xx as proof the children landed.
146
161
  - **Adding an `InherentRecordChildren` row is a breaking-shape change, not a feature flag.** It
147
162
  alters the default response for **every** existing caller of that route — including 1.0 importers
148
163
  and NetSuite sync — so the blast radius has to be surveyed first. The transfer-order build
@@ -364,6 +379,13 @@ ACL gate for calculated fields lives at **:7216** in `getFullModelData()` — wh
364
379
  [V2 request logging](request-logging.md).
365
380
 
366
381
  ## Change history
382
+ - 2026-09-02 — Recorded that the inherent-child key rule **also governs the WRITE path**, so the
383
+ child **route** is the payload contract for a nested collection in a `POST` body: `V2.php:3248`
384
+ builds `$lookupInherentChildByFieldName` (route kebab→camel) and the CREATE branch reuses that
385
+ same lookup at `V2.php:5241` (`$childrenToCreate`), with unmatched keys collected into
386
+ `$unknownFields` at `:5251`. So renaming a `Core.Records.route` silently renames every writer's
387
+ payload key, with nothing in the database to warn about it. Verified against the engine while
388
+ making the Create Transfer Order modal post header + lines in a single request. (apeterson)
367
389
  - 2026-09-02 — Recorded that a **bridge** collection needs its own `InherentRecordChildren` row and
368
390
  that the **header** bridge is a different record from the item bridge: a transfer order's PO number
369
391
  reads through `312 → 325 purchase-orders-transfer-orders`, **not** `329
@@ -1096,19 +1096,49 @@ hits `Duplicate entry '251' for key 'messages.PRIMARY'`. The resolution that kee
1096
1096
  `Core/2026-09-02a - TalosAssistantSurfaceSeed.sql` +
1097
1097
  `Client_Nychh/2026-09-02a - TalosAssistantEnable.sql` put the Talos AI assistant behind the
1098
1098
  surface layer. The surface is `talos-assistant`, **type `DRAWER`**, **not record-bound**, with
1099
- `appId` **inherited from the `navigation` sibling by slug** (never a literal). Five elements,
1100
- **all seeded `isVisible = 0`** — grant-only:
1099
+ `appId` **inherited from the `navigation` sibling by slug** (never a literal). **TWO elements**,
1100
+ **both seeded `isVisible = 0`** — grant-only:
1101
1101
 
1102
- | Role | Count | Purpose |
1103
- |---|---|---|
1104
- | `config.role = 'featureEnabled'`, `config.key = 'feature'` | 1 | **MASTER SWITCH.** Its `isVisible` turns the launcher, the panel and the whole AI stream on or off. |
1105
- | `config.role = 'suggestion'` | 4 | one chip each (`pendingApproval`, `blockedApprovals`, `pendingFulfillment`, `statusCounts`): label → `labelMessageId`, the question actually sent → **`config.prompt`**, icon → `iconToken` + `iconWeightToken='solid'`, order → `sortOrder`. |
1102
+ | Element | Purpose |
1103
+ |---|---|
1104
+ | `config.role = 'featureEnabled'`, `config.key = 'feature'` | **MASTER SWITCH.** Its `isVisible` turns the launcher, the panel and the whole AI stream on or off. Stays a **plain boolean grant** — see why below. |
1105
+ | `config.role = 'suggestion'`, `config.key = 'suggestions'` | **THE WHOLE CHIP ROW, in ONE element.** `config.items` is an **ordered JSON array** of `{label, prompt, icon}` (`icon` = a literal FontAwesome name). Its own `isVisible` switches the entire row. |
1106
1106
 
1107
1107
  It replaced `DEFAULT_SUGGESTIONS` — four hardcoded **TOGa Supply** questions that lived inside
1108
1108
  `toga-blox`, a library five apps consume, so any host mounting the panel inherited Supply's
1109
1109
  prompts. FE side:
1110
1110
  [Talos integration](../../toga25-supply/features/talos-integration.md).
1111
1111
 
1112
+ ### ⚠ SUPERSEDED — the chips were briefly ONE ELEMENT PER CHIP. They are not.
1113
+
1114
+ The first cut of this seed wrote **five** elements: the marker plus **four `config.role='suggestion'`
1115
+ elements, one per chip** (`labelMessageId` for the label, `config.prompt` for the question,
1116
+ `iconToken`/`iconWeightToken` for the icon, `sortOrder` for the order). **That shape is gone** —
1117
+ replaced before anything ran past local. If you find a doc, a branch or a local DB in that shape, it
1118
+ is the old design. There is **no `config.prompt`**, no per-chip `labelMessageId`, and **no per-chip
1119
+ `sortOrder`** any more; order is array order.
1120
+
1121
+ **Why one element wins.** Per-chip elements let a tenant only switch **on** questions Core had
1122
+ already seeded, so changing a tenant's *wording* needed a new **Core** migration for every phrasing.
1123
+ These are the tenant's own business questions — Core must not gatekeep the wording. With one
1124
+ element a tenant **authors its own set** in a single `CONFIG` override, and adding or reordering a
1125
+ chip is **one row instead of a Core deploy**.
1126
+
1127
+ **Two costs, accepted deliberately — do not rediscover them:**
1128
+
1129
+ 1. **Chip labels are NOT translatable.** They are raw strings in `config`, not `Messages` keys, so
1130
+ `MessageTranslations` cannot reach them. (Partly moot: API-auth JWTs carry no language, so client
1131
+ translations never apply on those sessions anyway.)
1132
+ 2. **Only whole SETS are scopable, not individual chips — and a multi-role user resolves
1133
+ arbitrarily.** Two role-scoped `CONFIG` rows resolve by
1134
+ `ORDER BY (roleId IS NULL) DESC, (personaId IS NULL) DESC, id ASC`, so for a user holding **both**
1135
+ roles the **higher id simply wins**. There is no merge and no defined precedence between two peer
1136
+ roles.
1137
+
1138
+ That second cost is exactly **why the `featureEnabled` marker was NOT folded into the config**: a
1139
+ plain boolean grant keeps the grant-only model (and its clean shadowing rules) for the thing that
1140
+ actually gates access.
1141
+
1112
1142
  ### 🚨 A feature switch is a MARKER ELEMENT — never `Surfaces.isVisible`
1113
1143
 
1114
1144
  The override cascade is keyed by **`surfaceElementId`** and is **never applied to a `Surfaces`
@@ -1116,7 +1146,7 @@ row's own `isVisible`** (`_loadOverrides`, `Model/Core/Surface.php` ~L733–748)
1116
1146
  surface row silently does nothing.** So a whole-feature switch has to be a hidden **marker
1117
1147
  element** on the surface, exactly like the `sectionVisibility` and `approvalsEnabled` markers.
1118
1148
 
1119
- ### The prompt lives in `config.prompt`, NOT an `Actions` row
1149
+ ### The prompts live in `config.items[]`, NOT an `Actions` row
1120
1150
 
1121
1151
  `RUN_TALOS` **is** a member of the `Actions.type` ENUM, but **nothing in `dbchanges2` has ever
1122
1152
  seeded an `Actions` row** and there is **no FE action-registry entry** for it. `config` is emitted
@@ -1125,6 +1155,44 @@ wiring**. Same trade `Core/2026-08-12a` made choosing `config.path` over `Action
1125
1155
  the same discriminator as the BUTTON_BAR rule above: **seed an `Action` only when the behaviour
1126
1156
  lives in the registry.**
1127
1157
 
1158
+ ### 🚨 A `CONFIG` override REPLACES `config` wholesale — this is the load-bearing rule of the whole design
1159
+
1160
+ Verified in `_underscore` **`Model/Core/Surface.php:839`**: the decoded JSON is **assigned over**
1161
+ `$config` —
1162
+
1163
+ ```php
1164
+ $config = (is_array($value) || is_object($value)) ? $value : $config;
1165
+ ```
1166
+
1167
+ — **there is no `array_merge`.** Consequences, in order of how much time they cost:
1168
+
1169
+ - **A tenant override MUST restate `"role":"suggestion"` alongside its `items`.** Send only `items`
1170
+ and `config.role` is **wiped**, the frontend stops recognising the element, and **the chips
1171
+ silently disappear** — no error in the API, the console, or the bundle. Nothing anywhere says
1172
+ why.
1173
+ - **A top-level JSON array passes that check**, which is precisely why sending a bare
1174
+ `[{...},{...}]` is the tempting wrong move. It is accepted, stored, resolved — and roleless.
1175
+ - Verify a chip override by asserting the **role survived**, not just that the row exists:
1176
+ `JSON_UNQUOTE(JSON_EXTRACT(o.c_longValue,'$.role'))` must be non-NULL.
1177
+
1178
+ **Put the JSON in `c_longValue`, never `value`.** `value` is `varchar(255)` and any real chip set
1179
+ overflows it. The resolver falls back to `c_longValue` when `value` is `NULL` or `''`
1180
+ (`Surface.php:907`). `_castOverrideValue` `json_decode`s `CONFIG` / `VISIBILITY_RULE` /
1181
+ `ENABLED_RULE`, and on bad JSON it **`error_log`s and returns null** — so a typo degrades to "the
1182
+ override was never there", again silently.
1183
+
1184
+ ### Teardown belongs IN the file, and the ORDER is not optional
1185
+
1186
+ Both files carry a commented `TEARDOWN` block, because this shape changed **after** the files had
1187
+ already been run on local. Correct order:
1188
+
1189
+ 1. `DELETE` the `Client_Nychh.SurfaceOverrides` rows **first**.
1190
+ 2. Then the Core `SurfaceElements` rows.
1191
+ 3. Then the `Surfaces` row.
1192
+
1193
+ Reverse it and the grants point at element ids that no longer exist. The `talos.feature.name`
1194
+ `Message` is **deliberately left behind** — harmless, and re-used on a re-run.
1195
+
1128
1196
  ### ⚠ Second authoring pattern: AUTO_INCREMENT + `NOT EXISTS`, for a PURELY ADDITIVE seed
1129
1197
 
1130
1198
  The reserved-literal-id house style above (2026-08-24) remains the default. **These two files
@@ -1197,35 +1265,88 @@ user — including devteam — gets Talos. That matches how NYCHH already grants
1197
1265
  After a seed that consumes ids, post **"next `<Table>.id` = N"** for teammates. The number to
1198
1266
  publish is the **highest `MAX(id)+1` across EVERY environment**, not just local.
1199
1267
 
1268
+ #### `Core.Records` / `Core.RecordFields` — the two HAND-MAINTAINED id tables: ASK, don't derive
1269
+
1270
+ These two are **not** like `Surfaces`/`SurfaceElements`/`Messages`. Platform-wide they are the
1271
+ **only** tables whose ids are **team-assigned by hand**, which is precisely what makes them safe to
1272
+ inline as literals in a `Client_*` file across the Core/Client cluster split. Three rules, in order:
1273
+
1274
+ 1. **Ask the developer for the next free id.** The team maintains the running number and posts it
1275
+ after every id-consuming migration. That is the primary source — not a query, not the repo.
1276
+ 2. **Never `AUTO_INCREMENT` a `Records` / `RecordFields` insert.** A generated id differs per
1277
+ environment and instantly breaks every literal that points at it.
1278
+ 3. **Never infer the next id from `dbchanges2`** — **the repo lags production.** Rows get
1279
+ hand-applied ahead of (or instead of) a migration file, so the highest id in the repo is routinely
1280
+ *below* the real ceiling. Verify against **production** before running.
1281
+
1282
+ Consumed on **2026-09-01** by the transfer-order contact/notes work — verified against production
1283
+ first (`MAX(Records.id)` = **358**; `RecordFields` **2561–2563** free), then used: Records
1284
+ **357**/**358** and RecordFields **2550–2560** + **2561–2563**. **Handoff published: next
1285
+ `Records.id` = 359, next `RecordFields.id` = 2564.** ⚠ Re-verify against production immediately
1286
+ before your own run — that handoff is a snapshot, and the migrations that consumed it were still
1287
+ uncommitted at the time. See
1288
+ [Transfer Orders page](../../toga25-supply/features/transfer-orders-page.md).
1289
+
1200
1290
  ## Change history
1291
+ - 2026-09-02 — Wrote down the **`Core.Records` / `Core.RecordFields` id protocol**, the counterpart to
1292
+ this doc's reserved-block discipline: they are the only two hand-maintained-id tables platform-wide,
1293
+ so **ask the developer for the next free id**, never `AUTO_INCREMENT` them, and **never infer the
1294
+ next id from `dbchanges2` — the repo lags production** (rows are hand-applied ahead of files).
1295
+ Consumed 2026-09-01 by the transfer-order contact/notes work after verifying against production
1296
+ (`MAX(Records.id)` = 358): Records 357/358 and RecordFields 2550–2563. Handoff published: next
1297
+ `Records.id` = 359, next `RecordFields.id` = 2564. (apeterson)
1298
+
1201
1299
  - 2026-09-02 — Added the **Talos feature-gate seed pair** (`Core/2026-09-02a -
1202
1300
  TalosAssistantSurfaceSeed.sql`: surface `talos-assistant`, type **DRAWER**, not record-bound,
1203
- `appId` inherited from the `navigation` sibling by slug, 5 elements all `isVisible = 0`;
1204
- `Client_Nychh/2026-09-02a - TalosAssistantEnable.sql`: 5 client-wide grants). **DECIDED** four
1205
- things worth reusing: (1) a whole-feature switch must be a hidden **marker element**, because the
1206
- override cascade is keyed by `surfaceElementId` and is **never** applied to a `Surfaces` row's own
1207
- `isVisible` (`_loadOverrides`, `Surface.php` ~L733–748) — overriding the surface row silently does
1208
- nothing; (2) the chip prompt lives in **`config.prompt`**, not an `Actions` row — `RUN_TALOS` is in
1209
- the ENUM but no `Actions` row has ever been seeded and there is no FE registry entry, while
1210
- `config` is emitted verbatim, so it needs no new wiring; (3) a **purely additive, id-agnostic**
1211
- seed may use **`AUTO_INCREMENT` + `NOT EXISTS`** instead of reserved literal ids — the
1212
- discriminator is whether the file re-parents rows or a `Client_*` file must inline a Core id, and
1213
- the analogue is the original `navigation` seed; (4) **OFF is correct, not just cautious**, for a
1214
- feature that sends a tenant's live data to an external AI. **FIXED prod-readiness** (developer
1215
- caught it): Core and `Client_*` are on **separate clusters** in prod, so the client file's
1216
- `JOIN Core.SurfaceElements` cannot run there — restructured into **Section A** (single-cluster
1217
- join, local/beta only), **Section B** (read-only `SELECT` on the Core cluster printing ids + a
1218
- paste-able `VALUES` body), **Section C** (literal-id guarded `INSERT` on the client cluster), with
1219
- rollback/verify in both forms. **De-qualified the Core file** (`Core.Messages` → `Messages`) because
1220
- api2 can rename the Core schema via `[database] core_dbname`, while the per-client file **keeps**
1221
- its `Client_Nychh.` qualification so a wrong-cluster run fails loudly rather than granting another
1222
- tenant the feature. DISCOVERED that **`SurfaceOverrides`' unique key protects a client-wide grant
1223
- not at all** — MySQL treats NULLs in a unique key as distinct, and a client-wide grant is
1224
- `personaId`/`roleId`/`languageId` all NULL, so the `NOT EXISTS` guard is the *only* thing making a
1225
- re-run idempotent. Verified against the real DDL: `iconToken` nullable, `NEUTRAL`/`BUTTON`/`TEXT`/
1226
- `LEFT` valid, `Surfaces.type DRAWER` exists, `Surfaces` UNIQUE KEY is `(slug, appId)`. Local run
1227
- produced `SurfaceElements` **216–220** (`navigation` = surface 39, appId 1); **beta and prod NOT
1228
- yet run**. (apeterson)
1301
+ `appId` inherited from the `navigation` sibling by slug; `Client_Nychh/2026-09-02a -
1302
+ TalosAssistantEnable.sql`). **REBUILT mid-session and this entry records the FINAL shape:** Core
1303
+ seeds **TWO** elements, both `isVisible = 0` — the `config.role='featureEnabled'` marker, and
1304
+ **ONE** `config.role='suggestion'` / `config.key='suggestions'` element whose `config.items` is an
1305
+ ordered JSON array of `{label, prompt, icon}`. The first cut wrote **five** elements (one per
1306
+ chip, prompt in `config.prompt`, order in `sortOrder`); **that shape is superseded** — per-chip
1307
+ elements let a tenant only switch on wording **Core** had already seeded, so every rephrasing
1308
+ needed a Core migration for questions that are the tenant's own business. NYCHH now writes **three**
1309
+ rows: two `IS_VISIBLE='1'` grants (`feature`, `suggestions`) plus **one `CONFIG` override
1310
+ authoring its own three questions**. Two costs accepted: chip labels are raw config strings, **not
1311
+ `Messages` keys**, so `MessageTranslations` can never translate them (partly moot — API-auth JWTs
1312
+ carry no language); and only whole **sets** are scopable, so a **multi-role user resolves
1313
+ arbitrarily** (`ORDER BY (roleId IS NULL) DESC, (personaId IS NULL) DESC, id ASC` — higher id
1314
+ wins, no merge). That is exactly why the `featureEnabled` marker was **kept a plain boolean grant**
1315
+ and not folded into the config. **DECIDED/DISCOVERED, reusable:** (1) a whole-feature switch must
1316
+ be a hidden **marker element**, because the override cascade is keyed by `surfaceElementId` and is
1317
+ **never** applied to a `Surfaces` row's own `isVisible` (`_loadOverrides`, `Surface.php`
1318
+ ~L733–748) — overriding the surface row silently does nothing; (2) 🚨 a **`CONFIG` override
1319
+ REPLACES `config` wholesale, it does not merge** — verified at `Surface.php:839`
1320
+ (`$config = (is_array($value) || is_object($value)) ? $value : $config;`, no `array_merge`), so a
1321
+ tenant override **must restate `"role":"suggestion"`** beside its `items` or the chips **silently
1322
+ disappear**; a bare top-level array passes that check, which is why it is the tempting wrong move;
1323
+ the JSON goes in **`c_longValue`, never `value`** (varchar(255) overflow — resolver falls back at
1324
+ `Surface.php:907`), and `_castOverrideValue` `error_log`s + returns null on bad JSON; (3) the chip
1325
+ prompts live in **`config.items[]`**, not an `Actions` row — `RUN_TALOS` is in the ENUM but no
1326
+ `Actions` row has ever been seeded and there is no FE registry entry, while `config` is emitted
1327
+ verbatim; (4) a **purely additive, id-agnostic** seed may use **`AUTO_INCREMENT` + `NOT EXISTS`**
1328
+ instead of reserved literal ids — the discriminator is whether the file re-parents rows or a
1329
+ `Client_*` file must inline a Core id, and the analogue is the original `navigation` seed; (5)
1330
+ **OFF is correct, not just cautious**, for a feature that sends a tenant's live data to an external
1331
+ AI; (6) both files now carry a commented **TEARDOWN** whose order is load-bearing — client
1332
+ `SurfaceOverrides` first, then Core `SurfaceElements`, then `Surfaces` (the `talos.feature.name`
1333
+ `Message` is left behind on purpose). **FIXED prod-readiness** (developer caught it): Core and
1334
+ `Client_*` are on **separate clusters** in prod, so the client file's `JOIN Core.SurfaceElements`
1335
+ cannot run there — restructured into **Section A** (single-cluster join, local/beta only),
1336
+ **Section B** (read-only `SELECT` on the Core cluster printing ids + a paste-able `VALUES` body),
1337
+ **Section C** (literal-id guarded `INSERT` on the client cluster), with rollback/verify in both
1338
+ forms. **De-qualified the Core file** (`Core.Messages` → `Messages`) because api2 can rename the
1339
+ Core schema via `[database] core_dbname`, while the per-client file **keeps** its `Client_Nychh.`
1340
+ qualification so a wrong-cluster run fails loudly rather than granting another tenant the feature.
1341
+ DISCOVERED that **`SurfaceOverrides`' unique key protects a client-wide grant not at all** — MySQL
1342
+ treats NULLs in a unique key as distinct, and a client-wide grant is `personaId`/`roleId`/
1343
+ `languageId` all NULL, so the `NOT EXISTS` guard is the *only* thing making a re-run idempotent.
1344
+ Verified against the real DDL: `iconToken` nullable, `NEUTRAL`/`BUTTON`/`TEXT`/`LEFT` valid,
1345
+ `Surfaces.type DRAWER` exists, `Surfaces` UNIQUE KEY is `(slug, appId)`. Run state: **local only**
1346
+ (`navigation` = surface 39, appId 1). The first attempt consumed `SurfaceElements` **216–220** under
1347
+ the old per-element shape; those were torn down and re-created under the two-element shape, so local
1348
+ ids advanced again — **element ids are `AUTO_INCREMENT` and therefore per-environment**, which is
1349
+ why prod reads them off Core in Section B. **Beta and prod NOT yet run.** (apeterson)
1229
1350
  - 2026-08-31 — Added the **denial-details** seed pair (`Core/2026-08-31a -
1230
1351
  SalesOrderDenialDetailsSurfaceSeed.sql` — Surface **47**, elements **170–176**, Messages
1231
1352
  **227–232**, plus a `readOnlyRule` marker on surface 41; and `Client_Compass/2026-08-31a -
@@ -6,7 +6,7 @@ project: TOGa Blox
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-23
9
+ updated: 2026-09-02
10
10
  owners: [apeterson]
11
11
  files:
12
12
  - toga-blox/src/components/AdvancedSelect/AdvancedSelect.tsx
@@ -62,6 +62,14 @@ Common (`AdvancedSelectBaseProps<T>`): `data: T[]`, `getOptionValue/getOptionLab
62
62
 
63
63
  - **`renderOption` prop is declared but unused** — option rendering is hardcoded (primary +
64
64
  secondary label, check/checkbox, avatar). Treat custom rendering as not yet supported.
65
+ - **⚠ GAP: there is NO footer / pinned-action slot.** A select that needs a persistent action in the
66
+ panel — the common "+ Add contact" when the option list is empty or wrong — cannot be built with
67
+ `AdvancedSelect` today. **And the obvious workaround does not work:** a sentinel option pushed into
68
+ `data` is **filtered out by the built-in search**, i.e. it disappears exactly when the user has
69
+ typed something and most needs it. Adding a real footer slot (rendered outside the virtualizer and
70
+ outside the search filter) is the fix; until then this pushes consumers to app-local selects.
71
+ Worked example: toga25-supply's app-local `PanelSelect` in the Create Transfer Order modal —
72
+ [Transfer Orders page](../../toga25-supply/features/transfer-orders-page.md).
65
73
  - Single-select with a `secondaryLabel` and menu closed shows a styled two-line value; the
66
74
  native input collapses to width 0 (`.inputCollapsed`).
67
75
  - Selected-float-to-top is **not** configurable.
@@ -70,4 +78,9 @@ Common (`AdvancedSelectBaseProps<T>`): `data: T[]`, `getOptionValue/getOptionLab
70
78
  - No built-in required validation or full ARIA listbox/option roles (aria-labels on buttons only).
71
79
 
72
80
  ## Change history
81
+ - 2026-09-02 — Recorded a real **gap**: `AdvancedSelect` has **no footer / pinned-action slot**, and
82
+ a sentinel option placed in `data` is filtered out by the component's own search — so a select that
83
+ needs a persistent "+ Add …" action cannot use it. This is why toga25-supply built an app-local
84
+ `PanelSelect` for the Create Transfer Order modal instead of consuming blox. Suggested fix: a
85
+ footer slot rendered outside the virtualizer and outside the search filter. (apeterson)
73
86
  - 2026-06-23 — Documented AdvancedSelect: virtualization, client/server search with abort, infinite scroll, keyboard nav, single/multi modes, and the unused `renderOption` caveat (apeterson).
@@ -14,7 +14,7 @@
14
14
  | [SSO redirect & public-vs-user session gating (useAuthenticationFlow)](features/sso-redirect-and-session-gating.md) | How 2.5 Supply decides, on every navigation, whether an anonymous visitor should be bounced to their client's SSO IdP instead of the local `/login` form. | toga25-supply/src/hooks/useAuthenticationFlow.ts, toga25-supply/src/routes.tsx, toga25-supply/src/contexts/AuthContext.tsx, toga25-supply/src/api/api.ts |
15
15
  | [Surface Frontend (DB-driven UI consumption, src/surface/)](features/surface-frontend.md) | The frontend consumer of the platform-wide Surface layer — DB-driven UI config fetched from `GET /v2/surfaces/meta?slug=<slug>` instead of statically-imported J | toga25-supply/src/App.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/DenialBanner.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/SalesOrderRecordModal.module.css, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/SalesOrderApprovalModalsLayout.tsx, toga25-supply/src/layout/RecordApprovalModal/RecordApprovalModalLayout.tsx, toga25-supply/src/layout/RecordApprovalModal/view/ApprovalTimelineView.tsx, toga25-supply/src/layout/RecordApprovalModal/ApprovalModal.module.css, toga25-supply/src/utils/formatDateTime.ts, toga25-supply/src/utils/index.ts, toga25-supply/src/surface/evaluateSurfaceRule.ts, toga25-supply/src/layout/RecordApprovalModal/, toga25-supply/src/contexts/AuthContext.tsx, toga25-supply/src/surface/applyColSpan.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/sectionRenderers.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getVisibleSections.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/SalesOrderApprovalSummaryGrid.tsx, toga25-supply/src/surface/useFetchSurfaceMeta.ts, toga25-supply/src/pages/SalesOrders/helpers/surfaceBundleToTenantFields.ts, toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/ServiceRequestRecordModalLayout.tsx, toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/view/ServiceRequestsView.tsx, toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/viewModel/useServiceRequestRecordModalLayoutModel.tsx, toga25-supply/src/pages/ServiceRequests/view/ServiceRequestRecordModalLayout/viewModel/FIELDS/apiFields.json, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderTopBar.tsx, toga25-supply/src/pages/SalesOrders/helpers/buildPatchedTenantFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/SalesOrderView.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/SalesOrderSummaryGrid.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getDetailSections.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderNotesSection.tsx, toga25-supply/src/pages/SalesOrders/helpers/cleanOrder.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/AdminNotesSection.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getAdminNotes.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/index.ts, toga25-supply/src/surface/evaluateSurfaceRule.ts, toga25-supply/src/surface/resolveElementState.ts, toga25-supply/src/pages/SalesOrders/helpers/evaluateEnableRule.ts, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrderRowRecordState.ts, toga25-supply/src/surface/actionRegistry.ts, toga25-supply/src/surface/componentRegistry.tsx, toga25-supply/src/surface/SurfaceActionBar.tsx, toga25-supply/src/surface/SurfaceSection.tsx, toga25-supply/src/surface/resolve.ts, toga25-supply/src/surface/types.ts, toga25-supply/src/surface/index.ts, toga25-supply/src/pages/Login/LoginPage.tsx, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx, toga25-supply/src/pages/SalesOrders/view/SurfaceRowActions.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrderVip.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderTopBar.tsx, toga25-supply/src/pages/Items/ItemsPage.tsx, toga25-supply/src/pages/Items/viewModel/useItemsPageViewModel.tsx, toga25-supply/src/pages/VendorItems/VendorItemsPage.tsx, toga25-supply/src/pages/VendorItems/viewModel/useVendorItemsPageViewModel.tsx, toga25-supply/src/pages/Inventory/Inventory.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/pages/Inventory/viewModel/FIELDS/index.ts, toga25-supply/src/fieldsConfig/index.ts, toga25-supply/src/layout/ItemRecordModalLayout/helpers/surfaceBundleToItemFields.ts, toga25-supply/src/layout/ItemRecordModalLayout/helpers/index.ts, toga25-supply/src/layout/ItemRecordModalLayout/viewModel/useItemRecordModalViewModel.tsx, toga25-supply/src/layout/ItemRecordModalLayout/ItemRecordModalLayout.tsx, toga25-supply/src/layout/ItemRecordModalLayout/components/ItemRecordView.tsx, toga25-supply/src/surface/useStatusColors.ts, toga25-supply/src/surface/SurfaceHeader.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/helpers/surfaceBundlesToDecisionFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/viewModel/useApprovalModalViewModel.tsx |
16
16
  | [Talos Integration (AppLayout host, live Aegra streaming, LangGraph→blox mapper)](features/talos-integration.md) | toga25-supply is the first host of the shared blox [Talos assistant](../../toga-blox/features/talos-assistant.md). | toga25-supply/src/api/talos.ts, toga25-supply/src/hooks/useTalosSession.ts, toga25-supply/src/hooks/useTalosThreads.ts, toga25-supply/src/hooks/useTalosThreads.test.ts, toga25-supply/src/hooks/useTalosSurface.ts, toga25-supply/src/hooks/useTalosSurface.test.ts, toga25-supply/src/App.tsx, toga25-supply/db-migrations/PLAYBOOK.md, dbchanges2/Core/2026-09-02a - TalosAssistantSurfaceSeed.sql, dbchanges2/Client_Nychh/2026-09-02a - TalosAssistantEnable.sql, toga25-supply/src/providers/TalosStreamProvider.tsx, toga25-supply/src/utils/talosMessages.ts, toga25-supply/src/utils/talosMessages.test.ts, toga25-supply/src/layout/AppLayout/AppLayout.tsx, toga25-supply/src/components/Header/Header.tsx, toga25-supply/src/components/Header/Header.module.css, toga25-supply/src/index.css, toga25-supply/src/assets/talos-owl.png |
17
- | [Transfer Orders page (TableView → Core surfaces → React page + record modal)](features/transfer-orders-page.md) | The Transfer Orders screen — list + read-only record modal — built end to end on 2026-08-28 from a Claude Design prototype. | toga25-supply/src/pages/TransferOrders/TransferOrders.tsx, toga25-supply/src/pages/TransferOrders/hooks/useTransferOrdersTableState.ts, toga25-supply/src/pages/TransferOrders/viewModel/useTransferOrdersPageViewModel.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrdersTableLayout/TransferOrdersTableLayout.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/TransferOrderRecordModalLayout.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/hooks/useTransferOrderRecord.ts, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/viewModel/useTransferOrderRecordModalLayoutModel.ts, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/view/TransferOrderRecordView.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/CreateTransferOrderModal.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/index.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/usePurchaseOrderItemRows.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTargetLocationOptions.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTransferSourceLocation.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useLocationContacts.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useCreateLocationContact.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/AddContactModal.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/TransferNotesField.tsx, dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql, toga25-supply/src/surface/useColumnVisibilityModalConfig.ts, toga25-supply/src/routes.tsx, dbchanges2/Core/2026-08-28a - TransferOrderSurfaceSeed.sql, dbchanges2/Core/2026-08-28b - TransferOrderListActionsSurfaceSeed.sql, dbchanges2/Client/2026-08-28a - TransferOrderStageThemeTokens.sql, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql, dbchanges2/Client_Nychh/2026-08-28a - TransferOrdersTableView.sql, dbchanges2/Client_Nychh/2026-08-28b - TransferOrdersNavigationEnable.sql, dbchanges2/Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql |
17
+ | [Transfer Orders page (TableView → Core surfaces → React page + record modal)](features/transfer-orders-page.md) | The Transfer Orders screen — list + read-only record modal — built end to end on 2026-08-28 from a Claude Design prototype. | toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useCreateTransferOrder.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/PanelSelect.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/ContactSelect.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/SelectTargetLocationModal.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/TransferItemPickerTable.tsx, _underscore/Model/Client/TransferOrder.php, _underscore/Model/Client/TransferOrderNote.php, _underscore/Model/Client/TransferOrderNoteType.php, dbchanges2/Client/2026-09-01b - TransferOrderNotesTables.sql, dbchanges2/Client/2026-09-01c - TransferOrderContactCreatedByAndDescription.sql, dbchanges2/Core/2026-09-01c - TransferOrderNotesRecords.sql, dbchanges2/Core/2026-09-01d - TransferOrderContactCreatedByAndDescriptionRecordFields.sql, toga25-supply/src/pages/TransferOrders/TransferOrders.tsx, toga25-supply/src/pages/TransferOrders/hooks/useTransferOrdersTableState.ts, toga25-supply/src/pages/TransferOrders/viewModel/useTransferOrdersPageViewModel.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrdersTableLayout/TransferOrdersTableLayout.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/TransferOrderRecordModalLayout.tsx, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/hooks/useTransferOrderRecord.ts, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/viewModel/useTransferOrderRecordModalLayoutModel.ts, toga25-supply/src/pages/TransferOrders/view/TransferOrderRecordModalLayout/view/TransferOrderRecordView.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/CreateTransferOrderModal.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/index.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/usePurchaseOrderItemRows.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTargetLocationOptions.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTransferSourceLocation.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useLocationContacts.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useCreateLocationContact.ts, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/AddContactModal.tsx, toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/TransferNotesField.tsx, dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql, toga25-supply/src/surface/useColumnVisibilityModalConfig.ts, toga25-supply/src/routes.tsx, dbchanges2/Core/2026-08-28a - TransferOrderSurfaceSeed.sql, dbchanges2/Core/2026-08-28b - TransferOrderListActionsSurfaceSeed.sql, dbchanges2/Client/2026-08-28a - TransferOrderStageThemeTokens.sql, dbchanges2/Client/2026-08-28b - TransferOrderItemsTimestamps.sql, dbchanges2/Client_Nychh/2026-08-28a - TransferOrdersTableView.sql, dbchanges2/Client_Nychh/2026-08-28b - TransferOrdersNavigationEnable.sql, dbchanges2/Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql |
18
18
  | [AWS Amplify Multi-Environment Deployment](workflows/amplify-deployment.md) | How `toga25-supply` deploys to **all** of its environments on AWS Amplify from a **single shared `amplify.yml`**. | toga25-supply/amplify.yml, toga25-supply/src/api/api.ts, toga25-supply/src/hooks/useAuthenticationFlow.ts, toga25-supply/vite.config.ts, toga25-supply/package.json, toga25-supply/.env.development |
19
19
  | [Cypress Testing Harness (component + e2e)](workflows/cypress-testing.md) | The Cypress test harness for the `toga25-supply` frontend, bootstrapped from scratch (`cypress` was already a dependency but there was no config, no `cypress/` | toga25-supply/cypress.config.ts, toga25-supply/cypress/tsconfig.json, toga25-supply/cypress/support/component.tsx, toga25-supply/cypress/support/component-index.html, toga25-supply/cypress/support/e2e.ts, toga25-supply/cypress/support/commands.ts, toga25-supply/cypress/support/fixtures.ts, toga25-supply/cypress/support/mocks/useApprovalModalViewModel.ts, toga25-supply/cypress/component/SalesOrderApprovalModalsLayout.cy.tsx, toga25-supply/cypress/component/RecordApprovalModalLayout.cy.tsx, toga25-supply/cypress/component/EnterPoNumberModal.cy.tsx, toga25-supply/cypress/e2e/salesOrderApproval.cy.ts |
20
20
  | [Porting a page (or query) from toga2-supply to toga25-supply](workflows/porting-a-page-from-toga2-supply.md) | `toga25-supply` re-implements pages that already exist in `toga2-supply`. | toga25-supply/src/pages/SalesOrders/viewModel/FIELDS/PRUDENTIAL/exportApiFields.json, toga25-supply/src/pages/SalesOrders/api/prudentialExportApi.ts, toga2-supply/src/pages/Orders/api/OrdersApi.ts, toga2-supply/src/components/ui/Tables/hooks/useExportableData.tsx |
@@ -29,6 +29,7 @@ files:
29
29
  - toga25-supply/src/assets/talos-owl.png
30
30
  related:
31
31
  - ../../toga-blox/features/talos-assistant.md
32
+ - ../../toga-blox/features/table-record-modal.md
32
33
  - ./surface-frontend.md
33
34
  - ../../dbchanges2/features/surface-layer-schema.md
34
35
  - ../../../clients/nychh/features/talos-assistant-enablement.md
@@ -139,15 +140,21 @@ navigation side effects (`onOpenRecord` → `/sales-orders?sales-orders=<id>`, `
139
140
  ### `useTalosSurface()` — the DB feature gate (2026-09-02)
140
141
 
141
142
  Reads the Core surface `talos-assistant` (type `DRAWER`, not record-bound) and returns
142
- **whether Talos is on for this tenant** plus the suggestion chips. Two element roles in the
143
- bundle:
143
+ **whether Talos is on for this tenant** plus the suggestion chips. **TWO elements** in the bundle:
144
144
 
145
- | `config.role` | Meaning |
145
+ | Element | Meaning |
146
146
  |---|---|
147
- | `featureEnabled` (one element, `config.key = 'feature'`) | **master switch** — its `isVisible` turns the launcher, the panel and the Aegra stream on or off |
148
- | `suggestion` (four elements) | one chip each: `labelMessageId` → label, **`config.prompt`** → the question actually sent, `iconToken`/`iconWeightToken` → icon, `sortOrder` → order |
149
-
150
- Chip keys: `pendingApproval`, `blockedApprovals`, `pendingFulfillment`, `statusCounts`.
147
+ | `config.role = 'featureEnabled'`, `config.key = 'feature'` | **master switch** — its `isVisible` turns the launcher, the panel and the Aegra stream on or off |
148
+ | `config.role = 'suggestion'`, `config.key = 'suggestions'` | **the whole chip row in ONE element.** `config.items` is an ordered array of `{label, prompt, icon}` (`icon` = a literal FontAwesome name). Its `isVisible` switches the entire row. |
149
+
150
+ **⚠ SUPERSEDED: chips were briefly ONE ELEMENT PER CHIP** — four `config.role='suggestion'`
151
+ elements with `labelMessageId` / `config.prompt` / `iconToken` / `sortOrder` and keys
152
+ `pendingApproval`, `blockedApprovals`, `pendingFulfillment`, `statusCounts`. **That shape is gone.**
153
+ There is no `config.prompt` and no per-chip `sortOrder`; **order is array order.** Reason: per-chip
154
+ elements meant a tenant could only switch on wording **Core** had already seeded, so rephrasing a
155
+ tenant's own business question needed a Core migration. One element lets a tenant author its own set
156
+ in a single `CONFIG` override. Costs (labels not translatable; whole sets scope, not chips):
157
+ [surface-layer-schema](../../dbchanges2/features/surface-layer-schema.md).
151
158
 
152
159
  **The FE FAILS CLOSED — deliberately, not defensively.** Missing bundle, failed fetch, absent
153
160
  marker element, or *still loading* all resolve to **disabled**. Talos answers questions from a
@@ -157,6 +164,19 @@ read as "on".
157
164
  `TalosStreamProvider` gained an **`enabled`** prop off the back of this: a tenant without Talos
158
165
  never even calls `GET /assistants`.
159
166
 
167
+ #### `readItems` is TOLERANT on purpose — one bad item costs one chip
168
+
169
+ `config.items` is a **hand-edited JSON column that differs per tenant**, so `readItems` **skips a
170
+ malformed item instead of throwing**. One bad entry in a tenant's `CONFIG` override must cost them
171
+ **one chip** — not every chip, and not a render crash. **14 tests** cover: `null`, a bare string, a
172
+ number, a missing `label`, a whitespace-only `label`, a non-string `icon`, `items` absent, `items`
173
+ not an array, and **array order being the authored order**.
174
+
175
+ 🚨 **The gate the FE cannot defend against:** a tenant `CONFIG` override that omits
176
+ `"role":"suggestion"` **wipes** it (a `CONFIG` override replaces `config` wholesale — verified at
177
+ `_underscore Model/Core/Surface.php:839`, no merge). The element then no longer matches, the chips
178
+ **silently disappear**, and there is no error anywhere. That is a seeding rule, not a parser fix.
179
+
160
180
  Seeding side, the marker-element choice, and the prod-cluster procedure:
161
181
  [surface-layer-schema](../../dbchanges2/features/surface-layer-schema.md).
162
182
 
@@ -242,6 +262,20 @@ turns in `message`, which renders an **empty bubble**. Caught only by writing th
242
262
 
243
263
  Anyone writing to `TalosMessageModel` by hand will hit this.
244
264
 
265
+ ## Gotcha — the launcher greys out under a modal via a ROOT ATTRIBUTE, and keyboard access is still open
266
+
267
+ blox `TableRecordModal` sets **`data-blox-modal-open` on `document.documentElement`** while open, and
268
+ blox dims the launcher under `:root[data-blox-modal-open]`. It applies to **every** `TableRecordModal`
269
+ in supply automatically — do not add a per-page dim. Why an attribute and not a prop: the launcher
270
+ renders in the **header**, outside the modal's subtree, so no shared React state reaches it (same
271
+ boundary-crossing trick as `--talos-dock-width`). Mechanism + the counter it needs:
272
+ [TableRecordModal](../../toga-blox/features/table-record-modal.md).
273
+
274
+ 🚨 **Known gap, unfixed:** the launcher is still **keyboard-reachable** behind a modal.
275
+ `pointer-events: none` stops the mouse only. Supply's header receives `inert` for the **side nav**
276
+ but **not** for modals — closing this is a **supply** change that affects every header control, so it
277
+ was flagged rather than patched.
278
+
245
279
  ## Gotcha — reserve dock width on the OUTER wrapper, not just `<main>`
246
280
 
247
281
  The docked panel is `position: fixed`, full-height on the right. The blox `Header` is in **normal
@@ -313,8 +347,13 @@ user resend. Decide deliberately rather than discovering it in QA.
313
347
  - 2026-09-02 — **Talos is now SURFACE-GATED, and OFF for every client by default** (this
314
348
  **corrects** the old "enabled for ALL clients, no per-client gating"): new `useTalosSurface()`
315
349
  reads the Core `talos-assistant` surface for a `config.role='featureEnabled'` **marker element**
316
- (master switch) plus four `config.role='suggestion'` chips whose text lives in `config.prompt` —
317
- replacing blox's hardcoded `DEFAULT_SUGGESTIONS`. The FE **fails closed**: missing bundle, failed
350
+ (master switch) plus **ONE** `config.role='suggestion'` element whose `config.items` is an ordered
351
+ array of `{label, prompt, icon}` — replacing blox's hardcoded `DEFAULT_SUGGESTIONS`. (**Superseded
352
+ mid-session:** the first cut was one element **per chip** with `config.prompt`/`sortOrder`; per-chip
353
+ elements meant only **Core** could author a tenant's wording, so one element + a tenant `CONFIG`
354
+ override replaced it. `readItems` is deliberately tolerant — a malformed item is **skipped**, not
355
+ thrown, so one bad entry in a hand-edited tenant config costs one chip, not the row; 14 tests.)
356
+ The FE **fails closed**: missing bundle, failed
318
357
  fetch, absent marker, or still-loading all resolve to disabled, and `TalosStreamProvider` gained
319
358
  an `enabled` prop so an ungranted tenant never calls `/assistants`. **NYCHH is the only granted
320
359
  tenant.** **BUILT the history drawer + new chat** (`useTalosThreads` maps Aegra threads → blox
@@ -330,7 +369,10 @@ user resend. Decide deliberately rather than discovering it in QA.
330
369
  `isError` on a failed **background** refetch while keeping the data, so one bad 4s poll flipped a
331
370
  good list into error; new pure `threadsStatusFrom` returns `"error"` only when there is nothing to
332
371
  show. Excluded the **`talos`** query namespace from the `localStorage`-persisted cache (chat
333
- titles + assistant ids are session data). (apeterson)
372
+ titles + assistant ids are session data). **FIXED the launcher not greying out under a record
373
+ modal** — blox now marks `data-blox-modal-open` on the root element and dims the launcher; the
374
+ cause was **not** z-index. **Known gap flagged, not fixed:** the launcher stays **keyboard**-
375
+ reachable behind a modal, and closing that is a supply header (`inert`) change. (apeterson)
334
376
  - 2026-09-01 — **BUILT the live streaming integration.** Added `src/api/talos.ts` (env chokepoint +
335
377
  runtime `GET /assistants` lookup — `assistantId` is **per-tenant**, never pinned in env),
336
378
  `useTalosSession` (gates on `isAuthenticated`, not on a token, because blox leaves a **public**
@@ -9,6 +9,18 @@ status: active
9
9
  updated: 2026-09-02
10
10
  owners: [apeterson, bala]
11
11
  files:
12
+ - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useCreateTransferOrder.ts
13
+ - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/PanelSelect.tsx
14
+ - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/ContactSelect.tsx
15
+ - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/SelectTargetLocationModal.tsx
16
+ - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/view/TransferItemPickerTable.tsx
17
+ - _underscore/Model/Client/TransferOrder.php
18
+ - _underscore/Model/Client/TransferOrderNote.php
19
+ - _underscore/Model/Client/TransferOrderNoteType.php
20
+ - dbchanges2/Client/2026-09-01b - TransferOrderNotesTables.sql
21
+ - dbchanges2/Client/2026-09-01c - TransferOrderContactCreatedByAndDescription.sql
22
+ - dbchanges2/Core/2026-09-01c - TransferOrderNotesRecords.sql
23
+ - dbchanges2/Core/2026-09-01d - TransferOrderContactCreatedByAndDescriptionRecordFields.sql
12
24
  - toga25-supply/src/pages/TransferOrders/TransferOrders.tsx
13
25
  - toga25-supply/src/pages/TransferOrders/hooks/useTransferOrdersTableState.ts
14
26
  - toga25-supply/src/pages/TransferOrders/viewModel/useTransferOrdersPageViewModel.tsx
@@ -50,6 +62,11 @@ related:
50
62
  - ../../../../clients/nychh/profile.md
51
63
  - ../../../../clients/nychh/features/transfer-order-inventory-quantities.md
52
64
  - ../../worker2/features/netsuite-transferorder-outbound-push.md
65
+ - ../../api2/features/nested-relationship-writes.md
66
+ - ../../_underscore/features/note-record-authorship.md
67
+ - ../../toga-blox/features/advanced-select.md
68
+ - ../../toga-blox/features/primary-table-templates.md
69
+ - ../../../standards/frontend.md
53
70
  ---
54
71
 
55
72
  ## Summary
@@ -62,12 +79,13 @@ reserved-id seed → per-tenant TableView → per-tenant nav opt-in → surface-
62
79
  **It was fully greenfield.** An audit of production, beta, sandbox-client **and all 36 client
63
80
  databases** on 2026-08-28 found **no transfer-order TableView anywhere**. Nothing was extended.
64
81
 
65
- A **Create Transfer Order** modal was started the same day and **reworked on 2026-09-01** against
66
- the live Claude Design export. Its data layer is wired (two of the three blocking data-model
67
- *questions* dissolved — availability comes from per-PO-line `_qty*`, and Locations are flat) and the
68
- order is scoped by a **purchase-order lock**, not by source location. 🚨 **But the picker still
69
- shows nothing for NYCHH**: every PO line reports availability 0 because of an upstream data gap,
70
- which is an open integration decision — see the blocker below.
82
+ A **Create Transfer Order** modal was started the same day, **reworked on 2026-09-01** against the
83
+ live Claude Design export, and **completed end to end on 2026-09-01/02** (front end + framework model
84
+ + Core metadata + client schema). It now writes the whole transfer — header **and** every line — in a
85
+ **single POST** with the lines nested under `transferOrderItems`, and the order is scoped by a
86
+ **purchase-order lock**, not by source location. 🚨 **The picker still shows nothing for NYCHH**:
87
+ every PO line reports availability 0 because of an upstream data gap, which is an open integration
88
+ decision — see the blocker below (a dev-only env flag exists to test around it locally).
71
89
 
72
90
  The vertical is **platform-shared with NYCHH as the first (and today only) tenant**: the Core
73
91
  surfaces and the stage theme tokens are neutral/shared, and only the TableView and the navigation
@@ -85,6 +103,10 @@ tenant.
85
103
  | `Client_Nychh/2026-08-28c - TransferOrderRecordAcl.sql` | one tenant | reproduces the **untracked** record/field ACL prod already has for records 312/313 |
86
104
  | `Client/2026-08-28b - TransferOrderItemsTimestamps.sql` | all tenants (template) | `TransferOrderItems.dtCreated`/`dtUpdated` (+ indexes), guarded `qtyCommitted` |
87
105
  | `Core/2026-08-28b - TransferOrderListActionsSurfaceSeed.sql` | Core | the `transfer-orders-list-actions` BUTTON_BAR + `common.columns.modal.*` Messages |
106
+ | `Client/2026-09-01b - TransferOrderNotesTables.sql` | all tenants (template) | `TransferOrderNotes` + `TransferOrderNoteTypes` — **ship empty and are currently UNUSED** (see the Notes decision) |
107
+ | `Core/2026-09-01c - TransferOrderNotesRecords.sql` | Core | Records **357**/**358**, RecordFields **2550–2560**, `InherentRecordChildren` 312→357 — **also unused, and 357/358 have NO NYCHH ACL grant** |
108
+ | `Client/2026-09-01c - TransferOrderContactCreatedByAndDescription.sql` | all tenants (template) | `TransferOrders.contactId` (FK Contacts), `createdByUserId` (FK Users), `description varchar(255)` |
109
+ | `Core/2026-09-01d - TransferOrderContactCreatedByAndDescriptionRecordFields.sql` | Core | RecordFields **2561** `contactId` / **2562** `createdByUserId` / **2563** `description` on record 312, + `AclFieldPermissions` for roles **1** and **3** |
88
110
 
89
111
  All of them were audited against the cluster-isolation HARD RULE: **no `Core.*` reference in any
90
112
  `Client_*` file** (the only such strings are inside comments), no `UUID()`, all table references
@@ -217,14 +239,18 @@ literal is repointed** — nothing in the database enforces it.
217
239
  `useFetchSurfaceMetaGroup` pulls **all four record surfaces in one call**, and the view renders
218
240
  entirely from them: labels, column set, ordering, visibility. There is no hardcoded field list.
219
241
 
220
- **Two record requests, not one.** `TransferOrders` (record 312) has **no `InherentRecordChildren`
221
- row** (sales-orders/14 has seven), so `transferOrderItems` does *not* come back nested inside the
222
- record fetch the way `salesOrderItems` does. The modal reads the lines from the nested route
242
+ **Two record requests, not one.** The modal reads the lines from the nested route
223
243
  `GET /v2/transfer-orders/{uuid}/transfer-order-items`, with the child route read from the items
224
- surface's `config.childRoute` so the FE never hardcodes it. Adding the `InherentRecordChildren` row
225
- was **deliberately declined** — it changes the default response shape for *every* existing
226
- `/transfer-orders` caller including the 1.0 NetSuite importer, and that blast radius was out of
227
- scope to verify. See [v2-rest-query-contract](../../api2/features/v2-rest-query-contract.md).
244
+ surface's `config.childRoute` so the FE never hardcodes it.
245
+
246
+ > ⚠ **The reason for the second request is GONE — the read path was not revisited.** This build
247
+ > declined to add the `InherentRecordChildren` row (it changes the default response shape for
248
+ > *every* existing `/transfer-orders` caller, including the 1.0 NetSuite importer). **That decision
249
+ > was reversed later:** `Core.InherentRecordChildren` row **152** now declares
250
+ > `transfer-orders` (312) → `transfer-order-items` (313), so `/transfer-orders` **does** inline
251
+ > `transferOrderItems` today. The modal still makes two calls; collapsing it to one is available but
252
+ > unclaimed work. See
253
+ > [v2-rest-query-contract](../../api2/features/v2-rest-query-contract.md).
228
254
 
229
255
  **The record fetch raises `calcDepth` to 2.** `_Model_Client_Location` exposes
230
256
  `_addressLine1/_addressLine2/_addressCity/_addressState/_addressZip` as `FIELD_SQL` calculated
@@ -356,9 +382,163 @@ Related data-shape facts found the same day:
356
382
  an inline **Add Contact** path (`AddContactModal.tsx` + `useCreateLocationContact`).
357
383
  - **The populated join table is `Contacts_Locations`** (route `/contact-locations`, **Record 76**) —
358
384
  **not** `Locations_Contacts`. Binding the reversed name finds nothing.
359
- - **`TransferOrders` has NO notes/description column.** The notes field's persistence is an **open
360
- decision**: either `TransferOrders.description varchar(255)` (mirroring `SalesOrders`) or a
361
- `TransferOrderNotes` child table (mirroring `SalesOrderNotes`). Nothing is written yet.
385
+ - ~~**`TransferOrders` has NO notes/description column**~~ — **closed 2026-09-01: `description
386
+ varchar(255)`.** Both candidates were actually built; see
387
+ [the Notes decision](#the-notes-box-writes-transferordersdescription--the-transferordernotes-tables-are-built-and-unused).
388
+
389
+ ### The whole transfer is ONE POST — lines nested under `transferOrderItems`
390
+
391
+ `hooks/useCreateTransferOrder.ts`. Header **and** every line go out in a **single
392
+ `POST /transfer-orders`**, with the lines nested under the key **`transferOrderItems`**. This is
393
+ possible because `Core.InherentRecordChildren` row **152** now makes `transfer-order-items` (313) an
394
+ inherent child of `transfer-orders` (312).
395
+
396
+ **Why one request, not three.** An earlier draft posted the header and then each line separately and
397
+ could leave a **half-written transfer** behind on a mid-sequence failure. One API request is one
398
+ API transaction — the front controller commits the umbrella connection on `isSuccess` and rolls the
399
+ data back on failure — so the nested POST is all-or-nothing **without** a scripted endpoint.
400
+
401
+ > **DECIDED: no scripted API endpoint for transfer create.** Weighed with Jeff and dropped. The
402
+ > inherent-child single POST already gives the atomicity a scripted endpoint would have been written
403
+ > for, so the extra surface is not worth it.
404
+
405
+ **The nested write key is the child ROUTE in camelCase — verified in the engine, not guessed.**
406
+ `V2.php:3248` builds the lookup by converting each inherent child's `route` from kebab to camel, and
407
+ `V2.php:5241` uses that **same** lookup on the CREATE path (`$childrenToCreate[$key] = $val`). So
408
+ **renaming the Core route renames the payload key for every writer**, with nothing in the database
409
+ to warn you. Engine detail and the read-side twin:
410
+ [v2-rest-query-contract](../../api2/features/v2-rest-query-contract.md).
411
+
412
+ This closes both **required POST changes** recorded on 2026-09-02: `number` is no longer sent (so
413
+ `FIELD_AUTOINCREMENT` mints the `TA100000` sequence) and the lines ride nested in the same request
414
+ (so `_Model_Client_TransferOrder::postPost` sees them when it reads the order back for the NetSuite
415
+ push) — see
416
+ [Toga TransferOrder → NetSuite Push](../../worker2/features/netsuite-transferorder-outbound-push.md).
417
+
418
+ **FKs go out as nested `{ uuid }` objects under the field name WITHOUT the `Id` suffix** —
419
+ `contact: { uuid }`, `createdByUser: { uuid }`. Each is omitted entirely when absent rather than sent
420
+ as `null`: a location can have no contact, and an SSO session can lack a user uuid.
421
+
422
+ ### `TransferOrders.contactId` / `createdByUserId` / `description` — new schema, model and metadata
423
+
424
+ `Client/2026-09-01c` adds the three columns, `_underscore/Model/Client/TransferOrder.php` declares
425
+ them, and `Core/2026-09-01d` adds RecordFields **2561** (`contactId`), **2562** (`createdByUserId`),
426
+ **2563** (`description`) on record 312 plus `AclFieldPermissions` for roles **1** and **3**. This is
427
+ what replaced the Target Contact element's placeholder `valueKey` with a real binding.
428
+
429
+ > ⚠ **`createdByUserId` is CLIENT-SUPPLIED and therefore only as trustworthy as the client.** The FE
430
+ > reads the signed-in user's uuid from the **`user` localStorage key** and posts it. Nothing stamps
431
+ > it server-side from the JWT. This matches the existing SalesOrders precedent, which is the only
432
+ > reason it was done this way — **still open with Jeff** whether the platform should stamp it from
433
+ > the token instead.
434
+
435
+ ### The Notes box writes `TransferOrders.description` — the `TransferOrderNotes` tables are built and UNUSED
436
+
437
+ `view/TransferNotesField.tsx` + `hooks/useCreateTransferOrder.ts`. **DECIDED:** a transfer carries
438
+ **one free-text description**, not a thread of typed notes from different authors, so the modal's
439
+ Notes box writes `TransferOrders.description` (`varchar(255)`). The front end hard-caps the textarea
440
+ at `DESCRIPTION_MAX_LENGTH = 255` so the tail is never silently truncated on save.
441
+
442
+ 🚨 **Consequence: `TransferOrderNotes` / `TransferOrderNoteTypes` and their Core metadata are now
443
+ dead weight.** They were built earlier the same session (mirroring `PurchaseOrderNote` /
444
+ `PurchaseOrderNoteType` exactly) and are **already migrated**, but **nothing reads or writes them**:
445
+
446
+ - `Client/2026-09-01b` creates both tables — they **ship empty**.
447
+ - `Core/2026-09-01c` seeds Records **357** (`transfer-order-notes`) / **358**
448
+ (`transfer-order-note-types`), RecordFields **2550–2560**, and `InherentRecordChildren` 312→357.
449
+ - `_underscore/Model/Client/TransferOrderNote.php` + `TransferOrderNoteType.php`.
450
+ - ⚠ **Records 357/358 have NO NYCHH ACL grant**, so even a deliberate caller gets `EZ-1` today.
451
+
452
+ **Whether to drop them is an open question for Jeff** (see the open-items list). Leaving them is
453
+ harmless but they are a trap for the next reader: the tables exist, so "notes are supported" looks
454
+ true from the schema alone.
455
+
456
+ Wider platform finding from building them:
457
+ [note-record authorship is not recorded anywhere](../../_underscore/features/note-record-authorship.md).
458
+
459
+ ### Target Location picker + target-contact scoping
460
+
461
+ `view/SelectTargetLocationModal.tsx`, `hooks/useTargetLocationOptions.ts`,
462
+ `hooks/useLocationContacts.ts`, `hooks/useCreateLocationContact.ts`.
463
+
464
+ - **Flat-list picker with debounced search.** The breadcrumb path is assembled from
465
+ `parentLocation.uuid` **with a cycle guard** — a self- or mutually-parented row would otherwise
466
+ loop forever.
467
+ - **Target Contact options come from `Contacts` through the `Contacts_Locations` junction**, scoped
468
+ to the selected location.
469
+ - **`Locations` deliberately does NOT get a contact FK.** The junction is the right place, and it is
470
+ genuinely **many-to-many** (a location can have several contacts). That kills the tempting
471
+ `UNIQUE(locationId, type)` idea outright — **only `isPrimary` is a viable "the one contact"
472
+ marker**, and how it is scoped/enforced is still open with Jeff.
473
+ - **With NYCHH's data the common case is an EMPTY list** (2 of 31 locations have any contact at
474
+ all), so "+ Add contact" is usually the only clickable thing in the panel. Design accordingly —
475
+ an empty contact select here is normal, not a fetch bug.
476
+
477
+ ### `PanelSelect` — ONE app-local select control for the whole modal
478
+
479
+ `view/PanelSelect.tsx`, consumed by `view/ContactSelect.tsx` and `CreateTransferOrderModal.tsx`.
480
+
481
+ The Claude Design mockup draws **Target Contact and the purchase-order filter with the same
482
+ control** (`window.FormSelect`), so they are **one component** here rather than two lookalikes:
483
+ 38px trigger, search box inside the panel, green check on the chosen row, optional pinned footer
484
+ action, optional clear (×). `ContactSelect` is now a thin mapping over it. The PO filter is 200px,
485
+ carries **"All POs" as a ROW in the list** (not a clear button), and goes disabled with a lock
486
+ tooltip while lines are selected.
487
+
488
+ **Built app-local on purpose, per [front-end standard §21](../../../standards/frontend.md)** —
489
+ promote to blox only after a second consumer. blox's `AdvancedSelect` could not serve it today:
490
+ it has `renderOption` but **no footer slot**, and a sentinel option pushed into `data` would be
491
+ **filtered out by the very search that makes you want a pinned action**. Recorded as a blox gap on
492
+ [AdvancedSelect](../../toga-blox/features/advanced-select.md).
493
+
494
+ ### "Show more" on the picker's item cell — copied from the primary table, rules and all
495
+
496
+ `view/TransferItemPickerTable.tsx`. The rules come from blox `PrimaryTableBodyCell` (mechanism:
497
+ [primary-table-templates](../../toga-blox/features/primary-table-templates.md)):
498
+
499
+ 1. The toggle is offered **only when the text is genuinely clipped**, measured **on hover** — not on
500
+ every render.
501
+ 2. The collapsed toggle's visibility is **CSS `group-hover`, NEVER React state.** A `mouseleave` is
502
+ not guaranteed to fire, so a JS-driven toggle can get **stuck on screen over an unhovered cell**.
503
+ 3. Expanded switches the cell to a column so the value wraps **above** a "Show less" toggle and the
504
+ row grows (body cells align top for this).
505
+ 4. The toggle **`stopPropagation`s** so it never checks the row it sits in.
506
+
507
+ Colors come from the `--expandableCell-color-*` tokens. Cells cap at **300px** so truncation can
508
+ trigger at all inside an auto-layout table; the items table maxes at **355px** (the design's figure).
509
+
510
+ ### Blank Item column — NYCHH leaves `Items.title` empty
511
+
512
+ `hooks/usePurchaseOrderItemRows.ts`. The label chain is
513
+ `item.title || item.description || "(untitled item)"`, and `vendorItem.item.description` was added to
514
+ the requested `fields`.
515
+
516
+ **Deliberately NOT falling back to a part number** — Part Number already has its own column, so that
517
+ would duplicate a cell instead of naming the item. **The real fix is populating `Items.title`.**
518
+
519
+ ### ⚠ `VITE_TRANSFER_IGNORE_AVAILABILITY` — LOCAL TESTING ONLY, never in a deployed environment
520
+
521
+ `hooks/usePurchaseOrderItemRows.ts` line ~91. With `VITE_TRANSFER_IGNORE_AVAILABILITY=true` the
522
+ picker falls back to the PO line's own `quantity` instead of `_qtyAvailable`, so the create flow can
523
+ be exercised while the `_qty*` blocker above is unresolved.
524
+
525
+ > 🚨 **This disables the guard that stops a transfer requesting more than can be dispatched.** It
526
+ > must **never** be set in any deployed environment — dev/local only.
527
+
528
+ ### Where the ground truth for this UI lives
529
+
530
+ **Claude Design project `84fed1b2-b1ab-4243-9354-5d04f861bca0`:**
531
+
532
+ | Thing | File in that project |
533
+ |---|---|
534
+ | the transfer modal mockup | `demo/transfer-modal.jsx` |
535
+ | the shared select it uses (`window.FormSelect`) | `src/form-select.jsx` |
536
+ | the PO-filter wrapper (200px, doubles as the lock) | `FilterLockSelect` |
537
+ | written ground truth | `ground-truth-transfer-orders.md`, `ground-truth-transfer-orders-v2.md` |
538
+
539
+ **Read the mockup source, not a screenshot.** The 2026-09-01 rework happened precisely because the
540
+ first pass had been built from an image and diverged (one shared select vs. two, "All POs" as a row
541
+ vs. a clear button).
362
542
 
363
543
  ### Line-item fetch is off behind a FLAG, not commented out
364
544
 
@@ -380,8 +560,10 @@ Money (Unit Value / Total / Total Value) and Target Contact are **dropped for v1
380
560
  `SurfaceElements` with `isVisible = 0`**, so enabling any of them later is a one-row client override
381
561
  rather than another Core migration.
382
562
 
383
- ⚠ `TransferOrders` carries **no contact FK at all** today, so the Target Contact element's
384
- `valueKey` is a **placeholder** that needs a real binding before any client turns it on.
563
+ ~~⚠ `TransferOrders` carries **no contact FK at all** today, so the Target Contact element's
564
+ `valueKey` is a **placeholder**~~ — **closed 2026-09-01.** `TransferOrders.contactId` (FK
565
+ `Contacts`) exists and RecordField **2561** is granted to roles 1 and 3, so the element has a real
566
+ binding and enabling it is now genuinely a one-row client override.
385
567
 
386
568
  The items table's **PO column is order-level**: `TransferOrderItems` has no line-level PO, so the
387
569
  column binds the parent order's `purchaseOrder.number` and repeats on every row. For NYCHH it
@@ -413,15 +595,33 @@ visible in Core (neutral default; other tenants populate `TransferOrders.purchas
413
595
  - **Open dependency:** `Client/2026-08-28a` writes to `ThemeTokens`, a table created by
414
596
  `Client/2026-06-25a - SurfaceClientTables.sql`. Any prod tenant that never received `2026-06-25a`
415
597
  will error, and the full 36-tenant list was **not** audited.
416
- - **🚨 Two REQUIRED changes to the Create Transfer Order POST (2026-09-02), both for the new
417
- NetSuite push.** The push is triggered from `_Model_Client_TransferOrder::postPost` — see
418
- [Toga TransferOrder → NetSuite Push](../../worker2/features/netsuite-transferorder-outbound-push.md):
419
- 1. **Drop `"number"` from the POST body.** The modal currently sends e.g.
420
- `"number": "286906"`, and `FIELD_AUTOINCREMENT` only generates the new `TA100000` sequence when
421
- `number` arrives **NULL** — a supplied value silently wins, with no error.
422
- 2. **Send `transferOrderItems` NESTED in the same POST.** `postPost` reads the order back
423
- in-request; if the lines are posted in separate calls afterwards it sees **zero lines** and the
424
- push aborts (and the trigger would have to move).
598
+ - ~~**🚨 Two REQUIRED changes to the Create Transfer Order POST (2026-09-02)**~~ — **both done
599
+ 2026-09-01/02.** `number` is no longer sent (so `FIELD_AUTOINCREMENT` mints the `TA100000`
600
+ sequence) and `transferOrderItems` rides **nested in the same POST** (so
601
+ `_Model_Client_TransferOrder::postPost` sees the lines when it reads the order back for the
602
+ NetSuite push). See
603
+ [the single-POST section](#the-whole-transfer-is-one-post--lines-nested-under-transferorderitems).
604
+ - **Nothing was committed in ANY application repo for the 2026-09-01/02 work.** All of it — the
605
+ toga25-supply modal, `_underscore/Model/Client/TransferOrder*.php`, and the four new `dbchanges2`
606
+ files — sits **uncommitted in the working tree on branch `features-talos-transferorders`**. Treat
607
+ the migration ids as **reserved but unshipped** until that lands.
608
+
609
+ ### 🚩 Open items handed to Jeff (questions, NOT decisions)
610
+
611
+ None of these is settled; do not read a resolution into the code that exists.
612
+
613
+ 1. **`TransferOrders.number` has no minter — rows get NULL.** The front end deliberately does
614
+ **not** mint `MAX(number)+1`, because two concurrent creates would race. (`FIELD_AUTOINCREMENT`
615
+ mints `TA100000` only where that model change is deployed — see the push doc.)
616
+ 2. **`transferOrderStageId` is not set on create.** Nobody knows which `TransferOrderStages` row
617
+ NYCHH treats as the opening stage.
618
+ 3. **`createdByUserId`: client-supplied vs. JWT-stamped server-side.** Today it is client-supplied
619
+ from `localStorage`, matching SalesOrders.
620
+ 4. **`Contacts_Locations.isPrimary`: per-location or per-type, and how is it enforced?** The junction
621
+ is many-to-many, so a unique index on `(locationId, type)` is wrong.
622
+ 5. **The `_qty*` = 0 blocker** (see above) — integration/data-owner decision.
623
+ 6. **Whether to drop the now-unused `TransferOrderNotes` tables + Core metadata** (records 357/358,
624
+ RecordFields 2550–2560, `InherentRecordChildren` 312→357).
425
625
  - ~~No `transfer-orders-list-actions` BUTTON_BAR surface~~ — **closed** by `Core/2026-08-28b`
426
626
  (Surface 52, elements 187–189); the header is now `SurfaceActionBar`-driven like Sales Orders.
427
627
  - **Test data lives in the session scratchpad, never in dbchanges2** (24 seed orders + an unseed
@@ -480,6 +680,23 @@ deliberate, separate exception — see [surface-frontend](./surface-frontend.md)
480
680
  literal `name`.
481
681
  - **`calcDepth` defaults to 1**, so a calculated field on a record one FK hop out returns NULL with
482
682
  no error. Raise it explicitly when the payload crosses an FK.
683
+ - **⚠ Request FK fields as DOTTED PATHS, never as the bare column name.** The convention in this
684
+ repo is `vendorItem.item.title` / `parentLocation.uuid` — a bare `parentLocationId` in `fields`
685
+ **silently returns nothing**. FKs then **write** as nested `{ uuid }` objects under the name
686
+ *without* the `Id` suffix (`contact: { uuid }`). Both halves were got wrong once this session.
687
+ - **⚠ The nested WRITE key for an inherent child is the child ROUTE in camelCase.** Renaming a Core
688
+ route renames every writer's payload key, with no database-level warning
689
+ (`V2.php:3248` builds the lookup, `:5241` consumes it).
690
+ - **⚠ A collapsed "Show more" toggle must be hidden by CSS `group-hover`, not React state** — a
691
+ `mouseleave` is not guaranteed to fire, so a state-driven toggle gets stuck on screen over a cell
692
+ nobody is hovering.
693
+ - **⚠ `VITE_TRANSFER_IGNORE_AVAILABILITY` disables the over-transfer guard** — local only, never in
694
+ a deployed environment.
695
+ - **`TransferOrderNotes` exists but is dead.** The tables + Core metadata are migrated and empty;
696
+ notes persist as `TransferOrders.description`. Schema alone will mislead you here.
697
+ - **A note row's author is not recorded anywhere on the platform** — every `*Notes` model declares a
698
+ `contactId` that no code writes. See
699
+ [note-record authorship](../../_underscore/features/note-record-authorship.md).
483
700
  - **`_Model_Client_Item` has no `name` field** — the item title/description live in `partNumber`,
484
701
  `title`, `description`, `longDescription` (`Item.php:39-45`). Binding `name` yields nothing.
485
702
  - **`Client_Nychh` `AclFieldPermissions` on the Location address fields (Core RecordFields 70–74)
@@ -489,6 +706,37 @@ deliberate, separate exception — see [surface-frontend](./surface-frontend.md)
489
706
  earlier migration without names, so a name-based lookup on them finds nothing.
490
707
 
491
708
  ## Change history
709
+ - 2026-09-02 — **Finished the Create Transfer Order modal end to end** (front end + framework model +
710
+ Core metadata + client schema) and **closed both required POST changes**: the transfer now writes
711
+ header and every line in **ONE `POST /transfer-orders`** with the lines nested under
712
+ `transferOrderItems`, made possible by `InherentRecordChildren` row **152** (312→313). Verified in
713
+ the engine that the nested **write** key is the child **route** in camelCase — `V2.php:3248` builds
714
+ the kebab→camel lookup and `V2.php:5241` reuses it on the CREATE path — so renaming a Core route
715
+ renames every writer's payload key. **DECIDED: no scripted API endpoint** (the single POST is one
716
+ API transaction, so it is atomic already). **DECIDED: the Notes box writes
717
+ `TransferOrders.description` (varchar 255, FE hard-capped)**, which leaves the
718
+ `TransferOrderNotes`/`TransferOrderNoteTypes` tables + Core records 357/358 + RecordFields
719
+ 2550–2560 **built, migrated, empty and UNUSED** (and 357/358 have no NYCHH ACL grant) — whether to
720
+ drop them is open. Added `TransferOrders.contactId` / `createdByUserId` / `description` with
721
+ RecordFields **2561–2563** and role 1/3 field ACL, which gives the previously-placeholder Target
722
+ Contact element a real binding; ⚠ `createdByUserId` is **client-supplied** from the `user`
723
+ localStorage key, not JWT-stamped. Built the Target Location picker (flat list, debounced search,
724
+ `parentLocation.uuid` breadcrumb with a cycle guard) and scoped Target Contact through the
725
+ many-to-many **`Contacts_Locations`** junction — so `Locations` gets no contact FK and only
726
+ `isPrimary` is a viable "the one contact" marker. Built **one app-local `PanelSelect`** for both
727
+ Target Contact and the PO filter (per front-end standard §21; blox `AdvancedSelect` has no footer
728
+ slot and would let its own search filter out a sentinel row), and copied blox's four "Show more"
729
+ rules into the picker's item cell. Fixed the blank Item column with
730
+ `title || description || "(untitled item)"` (NYCHH leaves `Items.title` empty; deliberately no
731
+ part-number fallback, which would duplicate an existing column). Added the **local-only**
732
+ `VITE_TRANSFER_IGNORE_AVAILABILITY` escape hatch — 🚨 it disables the over-transfer guard and must
733
+ never be deployed. Recorded that the FE convention for FK fields is **dotted paths**
734
+ (`vendorItem.item.title`), that the UI ground truth is Claude Design project
735
+ `84fed1b2-b1ab-4243-9354-5d04f861bca0` (`demo/transfer-modal.jsx`, `src/form-select.jsx`,
736
+ `FilterLockSelect`), and six **open questions for Jeff** (no `number` minter, unset
737
+ `transferOrderStageId`, `createdByUserId` ownership, `isPrimary` scoping, the `_qty*` blocker, and
738
+ the dead notes tables). Nothing was committed in any application repo — all of it sits on
739
+ `features-talos-transferorders`. (apeterson)
492
740
  - 2026-09-02 — Recorded two **required** changes to the Create Transfer Order POST, both driven by
493
741
  the new [TransferOrder → NetSuite push](../../worker2/features/netsuite-transferorder-outbound-push.md):
494
742
  **drop `"number"`** (the new `TA100000` `FIELD_AUTOINCREMENT` sequence only generates when `number`
@@ -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)_ — 78 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
+ - **_underscore** (_Underscore) _(framework core)_ — 79 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 59 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)_ — 15 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: nychh
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-01
9
+ updated: 2026-09-02
10
10
  owners: [jcardinal, apeterson]
11
11
  files:
12
12
  - _underscore/Model/Nychh/Item.php
@@ -251,6 +251,34 @@ Until one of those lands, treat any NYCHH transfer-order availability figure as
251
251
  not as a bug to debug. Tracking the adjustment↔fulfillment link:
252
252
  [NYCHH inventory-adjustment → item-fulfillment linking](./netsuite-inventory-adjustment-fulfillment-link.md).
253
253
 
254
+ #### Second reference row, and two more eliminations (2026-09-02)
255
+
256
+ Re-hit on a **different** line and re-investigated from scratch, which is worth recording so the
257
+ next person does not pay for it a third time. Reference row:
258
+ `PurchaseOrderItems` uuid **`715ef031-003d-6742-871d-0d20de2fa40f`** (PO **80622**, line **21**).
259
+
260
+ | Source | `quantity` | `_qtyReceived` | `_qtyFulfilled` | `_qtyOnHand` | `_qtyAvailable` |
261
+ |---|---|---|---|---|---|
262
+ | V2 API response | **3270** (correct) | 0 | 0 | 0 | 0 |
263
+ | hand-run SQL on `Client_Nychh` | 3270 | **3270** | 0 | — | — |
264
+
265
+ `quantity` coming back correct on the very same object is the tell that this is **not** a fetch,
266
+ route, database or deploy problem. Two further eliminations from this pass:
267
+
268
+ - **The values arrive as a literal `0`, not as `null` and not absent.** That rules out
269
+ `AclFieldPermissions` **and** an insufficient `calcDepth` in one step — both of those produce a
270
+ missing/`null` field, never a `0`. (Consistent with the rule now on
271
+ [v2-rest-query-contract](../../../2.0/apps/api2/features/v2-rest-query-contract.md): a calculated
272
+ field returning `0` is almost never an ACL problem.)
273
+ - **No physical shadowing columns on `PurchaseOrderItems`.** A stored column of the same name would
274
+ win over the `FIELD_SQL` expression; the blank client template creates only **7** columns on that
275
+ table, so there is nothing to shadow.
276
+
277
+ **The receipts genuinely exist** (3270 received on this line), so the numbers are recoverable — which
278
+ is exactly the shape of option **(b)** above. Left **UNRESOLVED and handed to Jeff**; it needs the
279
+ same integration/data-owner decision, and the evidence above narrows it to the override's stock-in
280
+ source rather than anything in the request path.
281
+
254
282
  ### The role-3 grant migration is NOT the fix for the empty picker
255
283
 
256
284
  `dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql` grants **role 3 (API)**
@@ -385,6 +413,17 @@ pattern (default a NOT NULL non-writable column in the model, never send it) is
385
413
  session — and a *passing* drift check on the base `Model/Client/` file is what made it feel safe.
386
414
 
387
415
  ## Change history
416
+ - 2026-09-02 — **Re-hit the `_qty*` = 0 blocker on a second line and narrowed it two steps further.**
417
+ Reference row `PurchaseOrderItems` uuid `715ef031-003d-6742-871d-0d20de2fa40f` (PO 80622, line 21):
418
+ the V2 API returns `quantity: 3270` **correctly** while `_qtyReceived`/`_qtyOnHand`/`_qtyAvailable`/
419
+ `_qtyFulfilled` all come back **0**, and hand-run SQL on `Client_Nychh` gives received **3270** —
420
+ the receipts DO exist. Newly eliminated: (a) the values arrive as a **literal `0`, not null and not
421
+ absent**, which rules out `AclFieldPermissions` *and* an insufficient `calcDepth` in one step, since
422
+ both of those produce a missing/null field; (b) there are **no physical shadowing columns** on
423
+ `PurchaseOrderItems` (the blank client template creates only 7). That leaves the deployed override's
424
+ inventory-adjustment stock-in source as the cause — i.e. the same open blocker, now with evidence
425
+ pointing at resolution option (b). Still **unresolved; handed to Jeff.** (apeterson)
426
+
388
427
  - 2026-09-01 — **Diagnosed the "API returns 0, hand-run SQL returns 40" report, and it turned into a
389
428
  data blocker rather than a bug.** The API ran `_Model_Nychh_PurchaseOrderItem`'s
390
429
  inventory-adjustment `_qtyReceived` while the hand-run check used the **base** item-receipt
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.746",
3
+ "version": "1.0.747",
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",