toga-ai 1.0.746 → 1.0.748

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 -
@@ -11,7 +11,7 @@
11
11
  | [Primary Table templates (server/client, sizing, virtualization)](features/primary-table-templates.md) | `src/templates/PrimaryTable/` is the **production, wired-up table** built on the [Table component](table.md). | toga-blox/src/components/Table/themeConfig/toga.module.css, toga25-supply/src/themeConfig.json, toga-blox/src/templates/PrimaryTable/PrimaryTable.tsx, toga-blox/src/templates/PrimaryTable/PrimaryTableServerTemplate.tsx, toga-blox/src/templates/PrimaryTable/PrimaryTableClientTemplate.tsx, toga-blox/src/templates/PrimaryTable/PrimaryTableHeaderCell.tsx, toga-blox/src/templates/PrimaryTable/PrimaryTableBodyCell.tsx, toga-blox/src/templates/PrimaryTable/PrimaryTableRow.tsx, toga-blox/src/templates/PrimaryTable/PrimaryTableExpandableRow.tsx, toga-blox/src/templates/PrimaryTable/types.ts |
12
12
  | [TableRecordModal (record-detail modal shell)](features/table-record-modal.md) | `TableRecordModal` is a **generic, presentational modal shell** for showing a single table record (row) in detail — typically opened from a table row click. | toga-blox/src/components/TableRecordModal/TableRecordModal.tsx, toga-blox/src/components/TableRecordModal/index.ts, toga-blox/src/components/TableRecordModal/tableRecordModal.module.css |
13
13
  | [Table component (cells, action cells, filters & sorts, hooks, theming)](features/table.md) | The `Table` component (`src/components/Table/`) is the **TanStack Table v8** building block behind the [Primary Table templates](primary-table-templates.md). | toga-blox/src/components/Table/hooks/useFetchTablePageMeta.ts, toga-blox/src/api/types.ts, toga-blox/src/components/Table/index.ts, toga-blox/src/components/Table/types.ts, toga-blox/src/components/Table/utils/buildTanstackColumns.tsx, toga-blox/src/components/Table/utils/resolveCellType.tsx, toga-blox/src/components/Table/components/cellTypes, toga-blox/src/components/Table/components/actionCells, toga-blox/src/components/Table/components/columnFiltersAndSorts, toga-blox/src/components/Table/hooks, toga-blox/src/components/Table/themeConfig |
14
- | [Talos AI-Assistant Component (launcher + docked/drawer chat panel)](features/talos-assistant.md) | `Talos` is a **shared, pure-UI** AI-assistant component in `@agilant/toga-blox`: a header launcher button (`TalosLauncher`) plus a chat panel (`TalosPanel`) tha | toga-blox/src/components/Talos/TalosLauncher.tsx, toga-blox/src/components/Talos/TalosPanel.tsx, toga-blox/src/components/Talos/TalosMessage.tsx, toga-blox/src/components/Talos/types.ts, toga-blox/src/components/Talos/theme.ts, toga-blox/src/components/Talos/helpers.tsx, toga-blox/src/components/Talos/stub.ts, toga-blox/src/components/Talos/Talos.module.css, toga-blox/src/components/Talos/index.ts, toga-blox/src/components/Talos/TalosPanel.test.tsx, toga-blox/src/components/index.ts, toga-blox/src/components/Talos/README.md |
14
+ | [Talos AI-Assistant Component (launcher + docked/drawer chat panel)](features/talos-assistant.md) | `Talos` is a **shared, pure-UI** AI-assistant component in `@agilant/toga-blox`: a header launcher button (`TalosLauncher`) plus a chat panel (`TalosPanel`) tha | toga-blox/src/components/Talos/TalosLauncher.tsx, toga-blox/src/components/Talos/TalosPanel.tsx, toga-blox/src/components/Talos/TalosMessage.tsx, toga-blox/src/components/Talos/types.ts, toga-blox/src/components/Talos/theme.ts, toga-blox/src/components/Talos/helpers.tsx, toga-blox/src/components/Talos/stub.ts, toga-blox/src/components/Talos/Talos.module.css, toga-blox/src/components/Talos/index.ts, toga-blox/src/components/Talos/TalosPanel.test.tsx, toga-blox/src/components/index.ts, toga-blox/src/components/Talos/README.md, toga-blox/src/components/TableRecordModal/TableRecordModal.tsx |
15
15
  | [Toaster — host-token styling contract](features/toaster.md) | The blox `Toaster` is a CSS-Module component whose every visual property reads a `--toaster-*` custom property supplied by the **host app**. | toga-blox/src/components/Toaster/Toaster.module.css, toga-blox/src/components/Toaster/Toaster.tsx, toga-blox/src/components/Toaster/ToasterContext.tsx, toga2-commerce/src/styles/index.css, toga2-commerce/src/components/Toasters/ToasterList.tsx |
16
16
  | [Dynamic npm Publish Pipeline (branch → channel)](workflows/dynamic-publish-pipeline.md) | How `@agilant/toga-blox` (checkout folder `toga-blox-npm`, registry repo key `toga-blox`) publishes a per-environment npm **channel** (dist-tag) from a `_<mode> | toga-blox/.github/workflows/publish.yml, toga-blox/package.json, toga-blox/src/utils/getFontAwesomeIcon.tsx |
17
17
  | [Landing a Large Long-Lived Feature Branch (merge, never rebase)](workflows/landing-a-large-feature-branch.md) | The procedure for landing a long-lived, very large feature branch (months old, hundreds of commits) into `_production` in `toga-blox-npm`. | toga-blox/package.json, toga-blox/.github/workflows/publish.yml |
@@ -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).
@@ -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/TableRecordModal/TableRecordModal.tsx
@@ -15,6 +15,8 @@ files:
15
15
  related:
16
16
  - ../architecture.md
17
17
  - table.md
18
+ - talos-assistant.md
19
+ - ../../toga25-supply/features/talos-integration.md
18
20
  ---
19
21
 
20
22
  ## Summary
@@ -58,6 +60,29 @@ it opens the modal with `isOpen={!!selectedRow}` and renders the record inside `
58
60
  (often using the same `tableViewFields` metadata to format fields). `utils/resolveModalType.tsx`
59
61
  and `types.ts` are present but **empty/reserved** for a future field-type→renderer mapping.
60
62
 
63
+ ## `data-blox-modal-open` — the root-element marker (2026-09-02)
64
+
65
+ While open, `TableRecordModal` sets **`data-blox-modal-open` on `document.documentElement`**. Other
66
+ blox CSS reacts to it — today the Talos launcher dims under `:root[data-blox-modal-open]` (see
67
+ [Talos assistant](talos-assistant.md)).
68
+
69
+ **Why an attribute on the root element and not a prop or context.** The things that must react to
70
+ "a modal is open" render **outside this component's subtree** — the Talos launcher lives in the app
71
+ **header**. No shared React state reaches it. Threading a prop down would have touched **every
72
+ modal layout in the consuming app**. This is the same boundary-crossing trick as the existing
73
+ `--talos-dock-width` custom property.
74
+
75
+ **It is a COUNTER, not a boolean.** Modals stack — a record modal can open a create modal — and the
76
+ marker must **survive the inner one closing**. So:
77
+
78
+ - Each instance holds a **`holdsModalMarker` ref**, so a re-render cannot double-count and a close
79
+ cannot decrement twice.
80
+ - The marker is cleared at **three** points: the open→false transition's **animation end**, an
81
+ **unmount** safety net, and the **count reaching 0**.
82
+
83
+ Anything else that reads the marker gets stacking correctness for free. (Note this fixes stacking
84
+ for the *marker* only — the body-overflow gotcha below is unchanged.)
85
+
61
86
  ## Gotchas
62
87
 
63
88
  - **No portal** — renders inline in the React tree; a parent `overflow: hidden` can clip it.
@@ -66,4 +91,12 @@ and `types.ts` are present but **empty/reserved** for a future field-type→rend
66
91
  - CSS-variable defaults are hardcoded fallbacks; set vars on the DOM before mount to change sizes.
67
92
 
68
93
  ## Change history
94
+ - 2026-09-02 — Added **`data-blox-modal-open` on `document.documentElement`** while a modal is open,
95
+ so chrome rendered **outside** the modal subtree can react (first consumer: the Talos launcher dims
96
+ under `:root[data-blox-modal-open]`). Chosen over a prop/context because the launcher lives in the
97
+ app **header** — no shared React state reaches it, and threading a prop would touch every modal
98
+ layout in the host. It is a **counter, not a boolean**, because modals stack and the marker must
99
+ survive the inner one closing; each instance holds a `holdsModalMarker` ref so a re-render cannot
100
+ double-count and a close cannot decrement twice, and the marker clears at three points (open→false
101
+ animation end, unmount safety net, count reaching 0). (apeterson)
69
102
  - 2026-06-23 — Documented TableRecordModal as a generic modal shell: positions, render-prop children, body-scroll lock, and consumer-owned field rendering / a11y (apeterson).
@@ -21,7 +21,9 @@ files:
21
21
  - toga-blox/src/components/Talos/TalosPanel.test.tsx
22
22
  - toga-blox/src/components/index.ts
23
23
  - toga-blox/src/components/Talos/README.md
24
+ - toga-blox/src/components/TableRecordModal/TableRecordModal.tsx
24
25
  related:
26
+ - table-record-modal.md
25
27
  - ../architecture.md
26
28
  - ../workflows/dynamic-publish-pipeline.md
27
29
  - ../workflows/local-link-into-a-consumer-app.md
@@ -254,9 +256,42 @@ threadsStatus?: "loading" | "error" | "ready";
254
256
  `DEFAULT_SUGGESTIONS` — four hardcoded **TOGa Supply** questions — was **removed**. Tenant
255
257
  business copy must not sit inside a library five apps consume: any host that mounted the panel
256
258
  silently inherited Supply's prompts. `suggestions` now defaults to an **empty module-level
257
- constant**, and the host supplies the chips (supply reads them from the surface layer — see
259
+ constant**, and the host supplies the chips (supply reads them from the surface layer as **one
260
+ authored, ordered set per tenant** — see
258
261
  [Talos integration](../../toga25-supply/features/talos-integration.md)).
259
262
 
263
+ ### The launcher is DISABLED under an open modal — and the cause was NOT z-index (2026-09-02)
264
+
265
+ The launcher stayed bright and looked clickable while a record modal was open. The obvious diagnosis
266
+ (and the developer's first guess) was z-index. **It was wrong.** Checked, not assumed:
267
+
268
+ - `.launcher` has **no `z-index`** at all.
269
+ - blox `.header` has **no `z-index` and no `position`**; supply's `.headerActions` has neither.
270
+ - The modal `.backdrop` is `position: fixed; z-index: 40`.
271
+
272
+ So the launcher was **already painted under the backdrop and its clicks were already being
273
+ swallowed by it.** The real cause is purely visual: `.backdrop-right` / `.backdrop-left` is only
274
+ `rgba(0, 0, 0, 0.2)`, and a 20% black wash over a **white** button still reads as active next to
275
+ dimmed header chrome.
276
+
277
+ **Fix — a real disabled state, not a z-index change.** `TableRecordModal` sets
278
+ **`data-blox-modal-open` on `document.documentElement`**, and `Talos.module.css` dims the launcher
279
+ under `:root[data-blox-modal-open]`: `opacity: .5`, `filter: grayscale(1)`,
280
+ `pointer-events: none`, plus **stopping the `.launcher::before` orbit animation** (a spinning ring
281
+ on a greyed-out button still reads as live). Marker mechanics and why it is a **counter**:
282
+ [TableRecordModal](table-record-modal.md).
283
+
284
+ This applies to **every** `TableRecordModal` in a host app automatically.
285
+
286
+ 🚨 **Known gap, left unfixed.** The launcher is still **keyboard-reachable** behind a modal —
287
+ `pointer-events: none` stops the **mouse only**. Supply's header takes `inert` for the side nav but
288
+ not for modals, and closing that is a **host** change affecting all header controls, so it was
289
+ flagged to the developer rather than patched here.
290
+
291
+ **Reusable lesson:** before reaching for z-index, read the actual computed stack. "Looks clickable"
292
+ and "is clickable" are different bugs, and a translucent scrim is a *contrast* problem, not a
293
+ *stacking* one.
294
+
260
295
  ### Tests (first ever for this component)
261
296
 
262
297
  `TalosPanel.test.tsx` — **31 Vitest + RTL Talos tests** as of 2026-09-02 (8 at inception), all
@@ -324,6 +359,20 @@ Rollup *"failed to resolve import"* for a component they never referenced). blox
324
359
  currently consumes it via a local symlink.
325
360
 
326
361
  ## Change history
362
+ - 2026-09-02 — **FIXED the launcher staying bright and clickable-looking under an open record
363
+ modal — and the cause was NOT z-index.** `.launcher` has no `z-index`, blox `.header` has no
364
+ `z-index`/`position`, and the modal `.backdrop` is `fixed; z-index: 40`, so the launcher was
365
+ **already** behind the backdrop with its clicks **already** swallowed. The real cause was contrast:
366
+ `.backdrop-right`/`-left` is only `rgba(0,0,0,0.2)` and a 20% wash over a white button still reads
367
+ as active. Fix is a real disabled state — `TableRecordModal` sets **`data-blox-modal-open` on
368
+ `document.documentElement`** (a **counter**, since modals stack) and `Talos.module.css` applies
369
+ `opacity: .5` / `grayscale(1)` / `pointer-events: none` and **stops the `.launcher::before` orbit
370
+ animation** under `:root[data-blox-modal-open]`. A root attribute rather than a prop because the
371
+ launcher renders in the host **header**, outside the modal subtree — the same trick as
372
+ `--talos-dock-width`. Applies to every `TableRecordModal` automatically. **Known gap flagged, not
373
+ fixed:** the launcher is still **keyboard**-reachable behind a modal; `pointer-events: none` stops
374
+ the mouse only, and adding `inert` is a host-header change. Suite at **31 Talos + TableRecordModal
375
+ tests**; blox `dist/` rebuilt. (apeterson)
327
376
  - 2026-09-02 — **FIXED the `renderMessages` slot silently killing the in-flight loading UI**: the
328
377
  bubble is synthesized from `isStreaming`/`progress` into the panel's internal `msgs` array, and the
329
378
  host-render branch replaced that whole array, so a run showed **no feedback between submit and