toga-ai 1.0.1145 → 1.0.1147

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.
@@ -42,6 +42,7 @@
42
42
  | [ItemClasses / _Model_Client_ItemClass — who actually uses it (and why it is not free)](features/item-classification-itemclasses.md) | `ItemClasses` is a near-unused per-client lookup that 2 of 36 clients already repurpose (Northwell service categories, Prudential device tiers); open before reu |
43
43
  | [Item-Fulfillment Stage Lifecycle (picked/packed/shipped) & Order Status](features/item-fulfillment-stage-lifecycle-and-order-status.md) | Item-fulfillment stage lifecycle (picked→packed→shipped) driving the calculated order `_status`, with per-client status policies; open when working order status |
44
44
  | [Location, Unit (asset), Ticket Department — notes + files tables, LocationTypes icons](features/location-unit-department-notes-files.md) | Shared Client-DB notes/files child tables for Locations, Units (assets) and TicketDepartments, plus LocationTypes icon columns; open before adding notes/files t |
45
+ | [Model audit-user stamps (createdByUserId / updatedByUserId)](features/model-audit-user-stamps.md) | Framework-stamped `createdByUserId` / `updatedByUserId` on client models via `FIELD_USER_CREATED` / `FIELD_USER_UPDATED`; open before adding author/"who" column |
45
46
  | [DB-free unit testing for _underscore model interceptors](features/model-interceptor-unit-testing.md) | DB-free unit-test scaffold for `_underscore` model interceptor/validation logic (stub the persistence base, reflect into privates); open when testing a `prePost |
46
47
  | [_Model magic-field access (__get without __isset)](features/model-magic-field-access.md) | `_Model` has `__get` but NO `__isset`, so `isset()`/`??`/`empty()` on a magic DB field are unreliable or false; open before probing an optional model field or d |
47
48
  | [_Model::save() parent FK cascade — stored-field deadlocks + bare-parent save() validation](features/model-save-parent-cascade-stored-field-deadlock.md) | Why a child save deadlocks (MySQL 1213) or throws the PARENT's validation error: `_Model::save()` re-saves each FK parent as a bare model; open when a child ins |
@@ -6,10 +6,12 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-17
9
+ updated: 2026-10-25
10
10
  owners: [snaredla, jcardinal, bala, apeterson, rgirish]
11
11
  files:
12
12
  - _underscore/Model.php
13
+ - _underscore/Model/Core/Client.php
14
+ - _underscore/Component/ClientListMetrics/ClientListMetrics.php
13
15
  - _underscore/Model/Client/PurchaseOrder.php
14
16
  - _underscore/Model/Client/SalesOrder.php
15
17
  - _underscore/Model/Client/ServiceRequest.php
@@ -241,6 +243,23 @@ is the one to watch.**
241
243
  composite does not fail — it quietly returns the parent's number, and the child's override looks
242
244
  like it "did nothing."
243
245
 
246
+ ## A value that cannot be one SQL expression — read NULL, fill it in `postGet`
247
+
248
+ When the value lives in another database (another client DB, or Core vs Client — never join them, they are
249
+ separate clusters), declare the `_` field as `FIELD_SQL` whose expression reads `NULL`, then fill the page
250
+ right after the read in a **POST GET interceptor** (`postGet`). Nothing stored, no worker job — the team
251
+ default is calc fields, not worker updates, unless a worker is really needed.
252
+
253
+ Reference: Core.Clients listing (Core record 51, `_tier`, `_headquarters`, `_customerCount`, …) — `_Model_Core_Client::postGet` calls `_Component_ClientListMetrics::fill()`: profile from the
254
+ linked home `Customers` row, counts with one query per client in that client's own DB. Rules it follows:
255
+ - **ACL is the component's job** (the interceptor runs after api2's ACL): reach the client via the caller's
256
+ `Users_Clients`, READ on each counted record type in that client (row filter included), field READ on every
257
+ profile value. No access → `NULL`, never `0`. No request user (API credential) → fills nothing.
258
+ - Fill only fields the request asked for; a client DB that cannot be resolved is logged and stays `NULL`
259
+ (the listing never fails for it).
260
+ - Never settable, sortable or filterable (the value does not exist in the query) — keep the column's
261
+ `isSortable` / `isFilterable` off.
262
+
244
263
  ## Filtering on a calculated field through the V2 API — it works, bare name only
245
264
 
246
265
  A calculated field **can** be used in a V2 `where`, and it is the cheapest way to cut a big list —
@@ -295,6 +314,7 @@ Keep the expression cheap, or expect callers to pay for it on every list.
295
314
  rule: toga25-desk `docs/decisions/0153-*`, PLATFORM rule 37.
296
315
 
297
316
  ## Change history
317
+ - 2026-10-25 — Cross-DB values: NULL calc field filled in `postGet` (`_Component_ClientListMetrics`), no worker (TRUE-83181). (jcardinal)
298
318
  - 2026-10-17 — Count calc fields: listing columns only; detail-tab counts use the list total (TRUE-82903). (jcardinal)
299
319
  - 2026-09-17 — Recorded a live instance of the `self::` trap: `_Model_Client_Item`'s four composite
300
320
  fields (`_qtyOnHand`, `_qtyAvailable`, `_qtyBackordered`, `_qtyOnOrder`) used **`self::`**, so a
@@ -29,7 +29,7 @@ Stages are set; statuses are derived. A case's `_status` slug comes from its sta
29
29
 
30
30
  - **`_status`** (plain `FIELD_SQL`, text slug): stage's linked `CaseStatuses.slug` if `caseStageId` set → else `ownerUserId` set → `in-progress` → else `open`. Same rule as `_Model_Client_Ticket::_status`. Never add `FIELDOPT_SQL_TYPE` (a cast zeroes the slug).
31
31
  - **Blocker stages** (`roadblocked`, `on-hold`, `awaiting-user`) are `CaseStages` rows keyed by the new `CaseStages.slug`. Resume = set `caseStageId` to NULL (status falls back to owner/open).
32
- - **`Client.CaseNotes` / `_Model_Client_CaseNote`**: write-once reason notes (`caseId`, `caseStageId`, `note`). `authorUserId` is stamped in `::save()` from `_Model::getRequestUserId()`, never from the payload. ACL: create + read only.
32
+ - **`Client.CaseNotes` / `_Model_Client_CaseNote`**: write-once reason notes (`caseId`, `caseStageId`, `note`). Author = framework-stamped `createdByUserId` (replaced `authorUserId`, 2026-10-25; see [model-audit-user-stamps](./model-audit-user-stamps.md)), never from the payload. ACL: create + read only.
33
33
  - **Set-stage gate**: Case `prePut`/`prePatch` capture the before-stage; `postPut`/`postPatch`/`postPost` read the `case-set-stage` Surface `requiredRule` for the new stage slug and its status slug. If the rule needs a reason, a `CaseNotes` row for this case + stage must have been stored in this request (`CaseNote::countCreatedFor`) — else `_Exception_Validation` (400) and the whole request rolls back. Wired by 5 `Core.ApiPayloadInterceptors` rows on cases (recordId 222).
34
34
  - **`Ticket::createCase`** (`{"createCase":{}}` on PUT /tickets/{uuid}) now creates the case with **no stage** (it used to force the Open stage), so status derives.
35
35
  - Central ids: `Core.Records` 377 (case-notes); `Core.RecordFields` 2743-2750. SQL: dbchanges2 `Client/2026-10-14b`, `Client/2026-10-14c`, `Core/2026-10-14b`, `Client/2026-10-15a` (case-notes ACL), `Client/2026-10-15b` (cases-stages read ACL).
@@ -0,0 +1,70 @@
1
+ ---
2
+ title: "Model audit-user stamps (createdByUserId / updatedByUserId)"
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-10-25
10
+ owners: [jcardinal]
11
+ files:
12
+ - _underscore/Model.php
13
+ - _underscore/Model/Client/User.php
14
+ related:
15
+ - ./note-record-authorship.md
16
+ - ./model-save-vs-query-atomic-update.md
17
+ - ../../../standards/backend-php.md
18
+ - ../../dbchanges2/architecture.md
19
+ ---
20
+
21
+ Framework-stamped `createdByUserId` / `updatedByUserId` on client models via `FIELD_USER_CREATED` / `FIELD_USER_UPDATED`; open before adding author/"who" columns or showing created/updated by.
22
+
23
+ ## Summary
24
+
25
+ Standard (jcardinal, 2026-10-25, TRUE-83181): every client record table has `createdByUserId` and `updatedByUserId` right after `dtUpdated`. The `_Model` stamps them; nothing else writes them. Table rules: [backend-php → Audit-user stamps](../../../standards/backend-php.md). First 36 tables: dbchanges2 `Client/2026-10-25k`.
26
+
27
+ ## How it works
28
+
29
+ - Opt in per model (client-database models only): `public $createdByUserId = self::FIELD_USER_CREATED;`, `public $updatedByUserId = self::FIELD_USER_UPDATED;`.
30
+ - INSERT sets both. UPDATE sets `updatedByUserId` only when a field really changed. The bare FK parent-cascade save and stored-calculation refreshes do NOT change it.
31
+ - User = `_Model::getRequestUserId()` (api2 sets it after login). No request user (worker, cron, API key) -> NULL.
32
+ - API payload values are ignored: never writable, no 400. api2 drops those keys.
33
+ - api2's inherent-parent touch (raw UPDATE of `dtUpdated`) also sets `updatedByUserId` on a parent that declares the type.
34
+ - After construction the field reads as `FIELD_FOREIGNKEY` to `_Model_Client_User` with a `'stamp'` key. So api2 relations/joins work like any user FK: `createdByUser._name`, TableViewJoin to Users on `createdByUserId` (cellType PERSON). It is NOT registered as a parent dependency.
35
+ - Lists show "who" from these stored columns, never from Logs audit blocks (platform rule 13).
36
+
37
+ ## Gotchas
38
+
39
+ - RAW `_Query` INSERT/UPDATE are NOT stamped. Use `_Model::save()` when the stamp matters.
40
+ - NULL = machine write or pre-adoption row. Do not treat NULL as an error.
41
+ - ALL note tables use `createdByUserId` now (no `authorUserId` left): Announcements, AttributeNotes, CaseNotes, CertificationNotes, ContactNotes, CustomerNotes, IssuerNotes, LocationNotes, ServiceRequestNotes, TicketDepartmentNotes, UnitInternalNotes, UserNotes. See [note-record-authorship](./note-record-authorship.md).
42
+ - The 11 note-like tables (Announcements, AttributeNotes, CertificationNotes, ContactNotes, CustomerNotes, IssuerNotes, LocationNotes, ServiceRequestNotes, TicketDepartmentNotes, UnitInternalNotes, UserNotes) have the full standard leading columns `id, uuid, dtCreated, dtUpdated, createdByUserId, updatedByUserId`. Models opt into `FIELD_DATETIME_CREATED` / `FIELD_DATETIME_UPDATED` / `FIELD_USER_UPDATED`.
43
+ - `ServiceRequestNotes` keeps `dtStamp` (the note time; SLA first response and the Desk notes feed read it) AND has `dtCreated` (row creation time). An integration that passes an older `dtStamp` makes `dtStamp` earlier than `dtCreated`. Read `dtStamp` for note time.
44
+
45
+ ## Stamps are not change data
46
+
47
+ - Stamps are NEVER reported in `getFieldsUpserted()` (`Model.php`). So they do not appear as RecordField rows in the activity log, and notification change detection ignores them.
48
+
49
+ ## View-as
50
+
51
+ - api2 keeps stamping the VIEWED user, and logs `Record.userId` = the viewed user.
52
+ - The REAL user is stored in `Logs_<tenant>.Record.delegatorClientId` (`Core.Clients.id`) + `delegatorUserUuid` (`dbchanges2` `Logs_Client/2026-10-25a`).
53
+ - `V2::resolveLogDelegator` sets them from the signed token. NULL for API keys and for view-as of oneself. Not exposed by the API.
54
+
55
+ ## Backfill + note-table rename
56
+
57
+ - Rename SQL: `dbchanges2` `Client/2026-10-25q`, `Core/2026-10-25j` rename the 11 note RecordFields keys and keep their ids (no new central ids).
58
+ - TicketNotes old rows got `dtCreated` / `dtUpdated` backfilled from `dtStamp` (`Client/2026-10-25r`).
59
+ - Note-like tables completed: `Client/2026-10-25t` adds the missing columns + FK/index and backfills dates (ServiceRequestNotes `dtCreated`/`dtUpdated` from `dtStamp`; the others `dtUpdated` = `dtCreated`). `Core/2026-10-25m` adds 22 RecordFields (ids 3544-3565). `Client/2026-10-25u` adds read grants.
60
+
61
+ ## Change history
62
+ - 2026-10-25 — 11 note-like tables got full standard columns (dtUpdated, updatedByUserId, FKs, backfill, RecordFields 3544-3565, read grants) (TRUE-83181). (jcardinal)
63
+ - 2026-10-25 — Stamps excluded from `getFieldsUpserted()`; all note tables on `createdByUserId`; view-as real user logged as delegator; TicketNotes dates backfilled (TRUE-83181). (jcardinal)
64
+ - 2026-10-25 — Standard adopted; first 36 client tables migrated (TRUE-83181). (jcardinal)
65
+
66
+ ## Related
67
+
68
+ - [Note record authorship](./note-record-authorship.md)
69
+ - [backend-php standard](../../../standards/backend-php.md)
70
+ - [dbchanges2 architecture](../../dbchanges2/architecture.md)
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-19
9
+ updated: 2026-10-25
10
10
  owners: [apeterson, jcardinal]
11
11
  files:
12
12
  - _underscore/Model/Client/SalesOrderNote.php
@@ -64,11 +64,15 @@ Before building anything that shows or filters by note author:
64
64
 
65
65
  - **`contactId` on a `*Notes` model is an unwritten FK — treat it as always NULL.** Seven models, zero writers.
66
66
  - **A notes list UI has no author to show.** Design for it, or the field silently renders blank for every row.
67
- - **Exception — server-stamped author:** `CaseNotes`, `CustomerNotes`, `ContactNotes`, `IssuerNotes`, `CertificationNotes`, `AttributeNotes`, `UserNotes`, `LocationNotes`, `UnitInternalNotes`, `TicketDepartmentNotes` carry `authorUserId`, stamped in `::save()` from `_Model::getRequestUserId()` (write-once). Copy that pattern for the real fix. The `save()` body is now copied 10 times — **consolidation debt:** move it to a shared trait/base before adding another. See [Case status + stage gate](./case-status-and-stage-gate.md), [Customer related records](./customer-locations-files-notes.md), [Contact related records](./contact-notes-files-types.md), [Location / unit / department notes](./location-unit-department-notes-files.md).
67
+ - **Exception — server-stamped author:** `CaseNotes`, `CustomerNotes`, `ContactNotes`, `IssuerNotes`, `CertificationNotes`, `AttributeNotes`, `UserNotes`, `LocationNotes`, `UnitInternalNotes`, `TicketDepartmentNotes` carry framework-stamped `createdByUserId` ([model-audit-user-stamps](./model-audit-user-stamps.md)); no manual stamping in `save()` any more, so the old "save() body copied 10 times" debt is gone for the author part. Each model still keeps its own NEEDS_USER and write-once checks. Copy that pattern for the real fix. See [Case status + stage gate](./case-status-and-stage-gate.md), [Customer related records](./customer-locations-files-notes.md), [Contact related records](./contact-notes-files-types.md), [Location / unit / department notes](./location-unit-department-notes-files.md).
68
68
  - **Do not add author stamping to `UnitNotes`** — the BofA import writes it with no signed-in user and toga2-supply reads it by position. User-written asset notes use `UnitInternalNotes` ([why](./location-unit-department-notes-files.md#gotchas)).
69
- - **`dtStamp` is `FIELD_DATETIME_CREATED`** — stamped by the ORM on insert, the only provenance a note actually carries.
69
+ - **`dtStamp` is `FIELD_DATETIME_CREATED`** on the order/ticket/unit/part-request note models — stamped by the ORM on insert.
70
+ - **The 11 server-stamped note-like tables have the full standard columns** (`dtCreated`, `dtUpdated`, `createdByUserId`, `updatedByUserId`); see [model-audit-user-stamps](./model-audit-user-stamps.md).
71
+ - **`ServiceRequestNotes` keeps `dtStamp` AND has `dtCreated`.** `dtStamp` is the note time (SLA first response, Desk notes feed); `dtCreated` is row creation time. An integration passing an older `dtStamp` makes `dtStamp` earlier than `dtCreated`. Read `dtStamp` for note time.
70
72
 
71
73
  ## Change history
74
+ - 2026-10-25 — 11 note-like tables now have full standard leading columns incl. dtUpdated/updatedByUserId; ServiceRequestNotes dtStamp vs dtCreated gotcha (TRUE-83181). (jcardinal)
75
+ - 2026-10-25 — Author now framework-stamped `createdByUserId` on all server-stamped note models; manual save() stamping removed (TRUE-83181). (jcardinal)
72
76
  - 2026-10-19 — LocationNotes, UnitInternalNotes, TicketDepartmentNotes added (10 copies of save()); UnitNotes stays author-less (TRUE-82911). (jcardinal)
73
77
  - 2026-10-17 — UserNotes added; seven author-stamped note models now copy one save() body (TRUE-82872). (jcardinal)
74
78
  - 2026-10-17 — ContactNotes added as third server-stamped-author note model; save() body is a consolidation candidate (TRUE-82871). (jcardinal)
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-18
9
+ updated: 2026-10-25
10
10
  owners: [jcardinal, apeterson, bala]
11
11
  files:
12
12
  - _underscore/Model/Client/Language.php
@@ -64,7 +64,7 @@ Inputs come from `$api` (the same accessor `_Model_Client_TableView::meta` uses)
64
64
  4. **ACL composition, not absorption** — batch-resolve permissions via `_Model_Client_AclActionPermission`; drop elements whose `aclActionId` isn't permitted (ACL stays exactly as-is, see [acl-permission-chain](acl-permission-chain.md)).
65
65
  5. Batch-load (one integer-`IN` query each): `Messages`+`MessageTranslations` (referenced keys only), `Vocabularies`+`VocabularyTerms`, `Actions`, client `ThemeTokens`.
66
66
  6. `type=TABLE` → emit `{tableViewSlug}` only; the FE keeps calling the existing `TableView::meta` table pipeline (referenced, never duplicated).
67
- 7. Assemble the flat bundle. **Zero INSERTs.**
67
+ 7. Assemble the flat bundle. **Zero INSERTs.** `bundle.surface.parentSlug` = slug of `Surfaces.parentSurfaceId` (or `null`). A child surface reads shared copy from its parent by that slug — e.g. Desk "copy view" surfaces take their column headings from the parent listing surface; no code mapping (TRUE-83181).
68
68
 
69
69
  A `metaDebug()` inspector returns the resolved bundle plus the raw cascade layers (shipped with the resolver, not "later"). It is **not** named `debug()` — see gotchas.
70
70
 
@@ -344,6 +344,7 @@ Precedence is per layer and never mixed on one value: Surface = base < client <
344
344
  - [side-navigation-and-default-route](../../toga25-supply/features/side-navigation-and-default-route.md)
345
345
 
346
346
  ## Change history
347
+ - 2026-10-25 — Bundle sends `surface.parentSlug`; child surfaces take copy from the parent (TRUE-83181). (jcardinal)
347
348
  - 2026-10-18 — Shared preferences resolver now lives in `_underscore` as `_Component_SettingResolver`. TRUE-82907 (jcardinal)
348
349
  - 2026-10-17 — `IS_REQUIRED` override loses to `requiredRule`; use static required + `visibilityRule` (TRUE-82889). (jcardinal)
349
350
  - 2026-10-08 — Added "Layer boundary": Surface owns what users see, `*Settings` = preferences only (jcardinal decision; see Desk ADR 0140) (jcardinal)
@@ -6,13 +6,14 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-19
9
+ updated: 2026-10-25
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - _underscore/Model/Client/TableViewPreset.php
13
13
  related:
14
14
  - ./tableview-joins.md
15
15
  - ../../api2/features/api-payload-interceptors.md
16
+ - ../../api2/features/tableview-field-metadata.md
16
17
  ---
17
18
  A user's saved table view stored as one validated JSON `config`; open before adding anything new to what a saved view remembers.
18
19
 
@@ -31,6 +32,10 @@ of a table: columns, up to 3 sorts, filters, group, search. Per-user (`userId` N
31
32
  scalar only. Bad input → 400.
32
33
  - **`search`** (added 2026-10-19): optional string, trimmed, max `MAX_SEARCH_LENGTH` 255, blank → `null`; not a
33
34
  string or too long → 400. Old presets without it stay valid. Stored only, never used in SQL here.
35
+ - **`locks`** (added 2026-10-25): optional array of column keys (same token check, duplicates dropped, order kept;
36
+ not an array → 400). Key omitted when absent. The live lock state outside a saved view is a per-user pref
37
+ `column-locks:<tableSlug>`; with neither, the locked set defaults to the view's `TableViewFields.sticky='LEFT'`
38
+ columns (see [TableView field metadata](../../api2/features/tableview-field-metadata.md)).
34
39
 
35
40
  ## Gotchas
36
41
 
@@ -38,4 +43,5 @@ of a table: columns, up to 3 sorts, filters, group, search. Per-user (`userId` N
38
43
  its own validator. No error — the key just never comes back.
39
44
 
40
45
  ## Change history
46
+ - 2026-10-25 — `locks` added to the config whitelist; per-user `column-locks:<slug>` pref; default from `sticky='LEFT'` (TRUE-83181). (jcardinal)
41
47
  - 2026-10-19 — Created; `search` added to the config whitelist (TRUE-82911). (jcardinal)
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-19
9
+ updated: 2026-10-25
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - api2/Component/Api/CrossClient/CrossClient.php
@@ -137,6 +137,11 @@ Page 1 of 25 still fetches exactly 25; page 254 fetches 500 per pass — ~13 rou
137
137
  single-client read lets through. Example: Items `_label` (RecordFields 2599) had no `AclFieldPermissions` grant
138
138
  anywhere until 2026-10-19 (`Client/2026-10-19e`, `Core/2026-10-19g`; granted like `_imageUrl` 1276: Core Super
139
139
  User + client capability Base). Before a consumer names a field, check the grant exists for the caller's roles.
140
+ - **⚠ One failed client → `totalRecordCount > 0` with `pageRecordCount = 0`.** The other clients' totals are still
141
+ reported, but no rows are served because rows cannot be ordered globally without the failed client. A consumer must
142
+ treat "count > 0, no rows" as an error (Desk does), not as an empty page; show `crossClient.unauthorizedClientCount`
143
+ as a notice. Cause seen 2026-10-25: a local client DB missing a module migration (`Units.c_dateReceived`,
144
+ `_modules/netsuite/2026-10-22a`) — check that client's schema first.
140
145
  - **`curl_multi` busy-spin guard** — both multi loops `usleep(100)` when `curl_multi_select() === -1`.
141
146
  - **Known limitation — deep page-number paging is inherently O(page).** Serving page 4430 means materializing all 110,747 preceding rows into the cache. Adaptive chunking makes that cheaper, not cheap: cost still grows with depth, because a page **number** cannot be resolved without knowing everything before it. The durable answer is **exposing keyset cursors to the caller** so cost is independent of depth — the mechanism already exists (V2 supports `seekValues` + `seekUuid` correctly). Raised with the developer 2026-07-28 and **deliberately left unbuilt / not yet scoped**: a known limitation and candidate direction, not pending work.
142
147
 
@@ -150,6 +155,7 @@ Page 1 of 25 still fetches exactly 25; page 254 fetches 500 per pass — ~13 rou
150
155
  - [_Model magic-field access](../../_underscore/features/model-magic-field-access.md)
151
156
 
152
157
  ## Change history
158
+ - 2026-10-25 — One failed client gives a total but no rows; consumers treat it as an error (TRUE-83181). (jcardinal)
153
159
  - 2026-10-19 — One ungranted field (calc fields too) fails the whole cross-client read with 403; Items `_label` granted (TRUE-82911). (jcardinal)
154
160
  - 2026-10-17 — Home vs linked user rows; `Users_Clients` is the reach list, linked rows drift (TRUE-82872). (jcardinal)
155
161
  - 2026-10-17 — Fetch scope is `client=` OR own token, never both; key multi-client rows by client + record uuid (TRUE-82903). (jcardinal)
@@ -6,11 +6,15 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-23
9
+ updated: 2026-10-25
10
10
  owners: [apeterson, bala, jcardinal]
11
11
  files:
12
12
  - _underscore/Model/Client/TableView.php
13
13
  - _underscore/Model/Client/TableViewField.php
14
+ - _underscore/Model/Client/TableViewFieldClickTarget.php
15
+ - dbchanges2/Client/2026-10-25c - TRUE-83181 TableViewFieldsCellType.sql
16
+ - dbchanges2/Client/2026-10-25e - TRUE-83181 TableViewFieldsAttachTo.sql
17
+ - dbchanges2/Client/2026-10-25ze - TRUE-83181 TableViewFieldsClick.sql
14
18
  - toga25-blox/src/data-table/table-data/buildTableDataOptions.ts
15
19
  - dbchanges2/Client/2026-10-15i - TableViewFieldSortRecordField.sql
16
20
  - dbchanges2/Client/2026-10-15j - TableViewFieldSortRecordFieldAcl.sql
@@ -47,6 +51,13 @@ The columns of a 2.0 table view are defined by DB metadata, not code. Per-client
47
51
  - **`Client_*.TableViewFields`** — one row per **column** of a view. Key columns: `recordFieldId` (which field this column shows), `tableViewJoinId` (which join in the view the field is reached through), `slug` (the frontend/tanstack column id), `isVisible` (0 = projected-but-hidden), and the per-view **presentation flags** `isCopyable` / `isSortable` / `isFilterable` / `isGroupable`. These flags **are** per-view — the same underlying field can be copyable in one view and not in another.
48
52
  - **`Client_*.TableViewJoins`** — the view's joins; also drive `options.join`/`ojoin` on the data request (see [tableview-apiwhereclause-row-filtering](tableview-apiwhereclause-row-filtering.md)). How a join row becomes SQL — aliasing, chained multi-hop joins, and the ACL row the joined record needs — is in [tableview-joins](../../_underscore/features/tableview-joins.md).
49
53
 
54
+ **Every listing column is a `TableViewFields` row — no code-side roles, order or widths (TRUE-83181, all Client DBs).** Rule: everything database-driven, a small set of generic cell types. `GET /v2/table-views/meta` (`_Model_Client_TableView::meta()`) emits these per field; keys are slugs, never ids. All are seeded by SQL only and passed through unvalidated, so a new ENUM value needs no PHP change.
55
+ - **`cellType`** ENUM, 14 generic types: `ID`, `TITLE`, `STATUS`, `PERSON`, `PEOPLE`, `REFERENCE`, `CLIENT`, `SLA`, `DATE_RANGE`, `ADDRESS`, `FILE`, `RELATED_COUNTS`, `ACTIVITY`, `AI_SUGGESTION`. `NULL` = default renderer for the field type. A row with a `cellType` and **no** `recordFieldId`/`customRecordFieldId` is a **virtual column**: the front end computes it; no field ACL, no join, no api2 path, never the default sort.
56
+ - **`isColumn`** — the row renders as its own column (0 = data only / shown with another column).
57
+ - **`attachToSlug` + `displayAs` + `attachIcon`** — show this field WITH another column of the same view. `displayAs` ENUM `LEADING_CHIP`, `CHIP`, `TAG_LIST`, `COUNT`, `FLAG`, `SUBTEXT`; `attachIcon` = Font Awesome name for a count.
58
+ - **`isPinned`, `isLockedVisible`, `sticky`** (`LEFT`/`RIGHT`; any other value emits `null`). Default locked columns = `sticky='LEFT'`; per-user locks are in [table view presets](../../_underscore/features/tableview-presets.md).
59
+ - **Click to open a related list:** `clickAction` ENUM `OPEN_RELATED_LIST`, `clickTableViewId` (FK, `ON DELETE SET NULL`), `clickFilterSlug`, `clickSwitchSlug`, plus table **`TableViewFieldClickTargets`** (`matchValue` → `tableViewId`) so a row value picks the target view. Meta emits `click` = `{action, tableViewSlug, filterSlug, switchSlug, targets[]}`: the target view's rows whose `filterSlug` field = the clicked row's uuid. Targets the caller's roles cannot read are dropped; no readable target → `click: null`. Use case: a count cell opens its related rows.
60
+
50
61
  > **⚠ `isGroupable` is declared in the framework but has NO migration (as of 2026-08-27).** `_underscore` `9f296a71` (2026-08-26) added `public $isGroupable = self::FIELD_BOOLEAN;` to `_Model_Client_TableViewField` **and** named the column in `_Model_Client_TableView::meta()`'s hand-written `SELECT` — but no `dbchanges2` file adds it, in any branch or in the blank client template. Any environment whose framework branch carries that commit **500s (`EO-1`, MySQL 1054) on every table-meta request, for every tenant**, until the column is added to each `Client_*` DB. It was hand-applied to `sandbox-client` as `TINYINT UNSIGNED NULL … AFTER isFilterable` (matching its sibling flags); the real fan-out migration is still outstanding. Background: [framework branch ahead of schema](../../dbchanges2/workflows/framework-branch-ahead-of-schema.md).
51
62
  > **Where the column exists, verified 2026-08-28:** present on **local** and **sandbox-client** (both hand-applied), **absent on production and beta**. Operational consequence: a `TableViewFields` INSERT that **names `isGroupable`** runs fine locally and **fails on prod**. Omit the column from new TableView migrations until the fan-out migration lands — the `Client_Nychh/2026-08-28a` transfer-orders view does exactly that.
52
63
 
@@ -142,6 +153,7 @@ So the visible failure is a broken SQL query while the actual defect is one miss
142
153
  - **`type`/`precision` are canonical (Core-wide), not per-view.** Editing `Core.RecordFields` changes every view/client that shows the field; there is no per-view type override. `isCopyable`, `isSortable`, `isFilterable`, `isGroupable`, `isVisible` are the per-view knobs (on `TableViewFields`).
143
154
  - **`TableView::meta()` contains a hand-written `SELECT` that names `TableViewFields` columns explicitly.** So a new column on that table is required by **two** mechanisms — the model property *and* that query — and a grep for the property alone under-reports what a missing column breaks.
144
155
  - **⚠ Type `SELECT` only on a JOINED FK column.** `meta()`'s `TYPE__SELECT` branch reads `$tableViewJoins[$tableAlias]->record` (`TableView.php` ~L265-272); a main-table column has no join alias, so it breaks at runtime. For an enum column on the main table, expose a calc label field (e.g. `_status`) with a `<field>FilterOptions` static — see [calculated-column filter options](../../_underscore/features/calculated-column-filter-options.md).
156
+ - **⚠ Other clients still store lowercase `sticky` values** (e.g. toga25-supply views). `meta()` emits only `LEFT`/`RIGHT`, so lowercase rows read as not sticky. Deferred: upper-case the data and refactor toga25-supply when Desk deploys.
145
157
  - **`isGroupable` has no migration** — omit it from new TableView migrations (fails on prod/beta) until the fan-out migration lands. Composite unique keys read as unconstrained in `information_schema.COLUMNS`; use `information_schema.STATISTICS` / `SHOW INDEX`.
146
158
 
147
159
  ## Related
@@ -153,6 +165,7 @@ So the visible failure is a broken SQL query while the actual defect is one miss
153
165
  - [TableView joins](../../_underscore/features/tableview-joins.md)
154
166
 
155
167
  ## Change history
168
+ - 2026-10-25 — Listing column model: `cellType`, `isColumn`, attach/`displayAs`, pin/lock/`sticky`, related-list `click` + `TableViewFieldClickTargets` (TRUE-83181). (jcardinal)
156
169
  - 2026-10-23 — New column rows: `index` = MAX + offset, never a literal (1062 on the unique key) (TRUE-82871). (jcardinal)
157
170
  - 2026-10-17 — `SELECT` type is safe only on a joined FK column; main-table enum → calc label + FilterOptions. (jcardinal)
158
171
  - 2026-09-23 — Recorded that `Core.RecordFields.type` drives display/filter only and never sorting; that `NUMBER` on a text column blanks non-numeric rows (Compass `kit 205-207` bundles) while `BOOLEAN` fixes genuinely blank flag columns; and that the flag columns are NULLable so a `= 0` migration guard matches nothing. (apeterson)
@@ -6,7 +6,7 @@ project: Database Changes
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-10-22
9
+ updated: 2026-10-25
10
10
  owners: [jcardinal, mhammontree, bala, ajean]
11
11
  files:
12
12
  - Core/
@@ -28,7 +28,7 @@ The 2.0 SQL change-set repo: folder→database layout, the filename execution co
28
28
 
29
29
  Because execution order is purely alphabetical, **file naming is the contract.** Files are named with a leading **`YYYY-MM-DD`** date so alphabetical sort = chronological = intended execution order.
30
30
 
31
- **Critical rules:** every new `.sql` file **must** be named `YYYY-MM-DD<letter> - <Description>.sql` with a **mandatory lower-case letter** right after the date (`a` for the first file of that day in a folder, then `b`, `c`, …). One folder per database; place client changes under the matching `Client_<Name>` folder. **One file per database folder per change** — all of a change's SQL for one database goes in a single file (see *One file per database folder per change*). Never edit an already-run migration — add a new dated file instead. The `Client/` **blank must carry the baseline DATA seed** (roles, full ACL, reference/lookup tables, UI config) — **not just schema**; a schema-only blank produces non-functional clients. **A file may only reference tables in the ONE database its folder targets** — `Core`, `Client_*`, `Archive_*`, `Logs*`, and `Cache` are on **separate production clusters**, so any `OtherDatabase.Table` reference is unrunnable in production even though it works locally (see *Database isolation* below; enforced by the `dbchanges2-cluster-isolation` hook). **`Core.Records` and `Core.RecordFields` are the only two tables platform-wide with team-maintained `id`s** — ask the developer for the next value before inserting (never `AUTO_INCREMENT`), and hardcode those `id`s wherever they are referenced, foreign keys included.
31
+ **Critical rules:** every new `.sql` file **must** be named `YYYY-MM-DD<letter> - <Description>.sql` with a **mandatory lower-case letter** right after the date (`a` for the first file of that day in a folder, then `b`, `c`, …). One folder per database; place client changes under the matching `Client_<Name>` folder. **One file per database folder per change** — all of a change's SQL for one database goes in a single file (see *One file per database folder per change*). Never edit an already-run migration — add a new dated file instead. The `Client/` **blank must carry the baseline DATA seed** (roles, full ACL, reference/lookup tables, UI config) — **not just schema**; a schema-only blank produces non-functional clients. **A file may only reference tables in the ONE database its folder targets** — `Core`, `Client_*`, `Archive_*`, `Logs*`, and `Cache` are on **separate production clusters**, so any `OtherDatabase.Table` reference is unrunnable in production even though it works locally (see *Database isolation* below; enforced by the `dbchanges2-cluster-isolation` hook). **`Core.Records` and `Core.RecordFields` are the only two tables platform-wide with team-maintained `id`s** — ask the developer only which id to START on, per table, then number up from there (never `AUTO_INCREMENT`), and hardcode those `id`s wherever they are referenced, foreign keys included.
32
32
 
33
33
  ## File naming convention (the execution contract)
34
34
 
@@ -156,7 +156,7 @@ This is what makes the *Database isolation* rule practical — it is the reason
156
156
 
157
157
  ### Inserting into them — ASK FIRST, never rely on AUTO_INCREMENT
158
158
 
159
- **When a change inserts into `Core.Records` or `Core.RecordFields`, stop and ask the developer for the next `id` value(s)**, then write them as explicit literals:
159
+ **When a change inserts into `Core.Records` or `Core.RecordFields`, ask the developer only which id to START on for each table**, then number the rows up from there as explicit literals. Never ask for a count, a list of ids, or approval of the numbers.
160
160
 
161
161
  ```sql
162
162
  # Core/2026-07-31a - Add entitlements serviceAddressId field.sql
@@ -167,7 +167,7 @@ VALUES (4188, '<pre-generated v4 uuid>', 219, 'serviceAddressId');
167
167
 
168
168
  **Never omit `id` and let MySQL's `AUTO_INCREMENT` assign it.** The column's `AUTO_INCREMENT` definition still exists, but it is **not the source of truth** — allowing it to assign a value lets environments drift apart, and every hardcoded reference to that `id` (in other databases, in PHP, in other migrations) then points at the wrong row or nothing at all. There is no way to detect this from inside the migration; it silently produces wrong ACL/field wiring.
169
169
 
170
- Because the `id`s must be reserved by a human, **this cannot be guessed or derived** — asking is mandatory. Record the assigned values in a comment at the top of the file so the next reader knows they were allocated, not invented.
170
+ Because the start `id` is reserved by a human, **it cannot be guessed or derived** — asking for the start id is mandatory. Record the assigned values in a comment at the top of the file so the next reader knows they were allocated, not invented.
171
171
 
172
172
  **`/finish` always reports the central ids** — every `Records` / `RecordFields` id the ticket inserted (id → route / route.field) plus the next free ids, or "none used by this ticket". Every time, never skipped: the developer copies the next ids into Tools → Developers → Central IDs.
173
173
 
@@ -264,9 +264,10 @@ This lifecycle is automated by a local browser tool — see the [TOGa 2.0 Client
264
264
  One earlier migration (`Core/2026-05-08 … CronJobs_WorkerCleanup_insert`) used `UUID()` — that is a known violation, not a precedent to copy. First applied correctly in `Core/2026-09-29a - Record sprint and design CronJobs.sql` (new rows 83–86 carry pre-generated v4 literals; the recorded prod rows keep their existing prod uuids, which predate this rule).
265
265
  7. **Guard every registration/seed insert with `NOT EXISTS` (or `INSERT IGNORE`).** Rows that register platform behavior — `ApiPayloadInterceptors`, `Core.RecordFields`/`CustomRecordFields`, cron jobs, ACL rows — must be inserted only if not already present. An **unguarded** registration is not merely non-idempotent: on a fresh environment it can run **alongside a second migration that registers the same behavior**, producing a **double registration** (e.g. two interceptor rows for the same record → the interceptor fires twice). A real instance: the Rate WH `prePost` interceptor is registered unguarded in `Client_Rate/2026-07-15 - PreInterceptor.sql` (record 191, `minDepth NULL`); a second guarded registration would have double-fired it. See `clients/rate/features/whole-home-warranty-purchase-guard.md`.
266
266
  8. **Never reference a database other than the folder's own target.** No `Core.Table` in a `Client_<Name>/` file, no `Client_X.Table` in a `Core/` file, and **no database qualifier at all** in the fan-out folders (`Client/`, `Logs_Client/`, `_modules/`). These databases are on **separate production clusters**; the query works locally and dies in production. Resolve foreign ids with a hardcoded v4 UUID literal or an in-database slug/natural key, or split the work into one file per database folder. See *Database isolation* above — enforced by the `dbchanges2-cluster-isolation` hook.
267
- 9. **Inserting into `Core.Records` or `Core.RecordFields`? ASK the developer for the next `id`** (the developer takes it in Tools → Developers → Central IDs — the source of truth; never guess from MAX(id), branches hold unreleased ids). These are the **only two tables on the platform with team-maintained primary keys**. Write the assigned `id` as an explicit literal — **never** let `AUTO_INCREMENT` assign it. Conversely, **always hardcode** these `id`s where they are referenced, including in foreign keys (`recordId`, `recordFieldId`) from other databases. See *`Core.Records` / `Core.RecordFields`* above.
267
+ 9. **Inserting into `Core.Records` or `Core.RecordFields`? ASK the developer only which `id` to START on, per table, then number up from there** (the developer takes it in Tools → Developers → Central IDs — the source of truth; never guess from MAX(id), branches hold unreleased ids). These are the **only two tables on the platform with team-maintained primary keys**. Write the assigned `id` as an explicit literal — **never** let `AUTO_INCREMENT` assign it. Conversely, **always hardcode** these `id`s where they are referenced, including in foreign keys (`recordId`, `recordFieldId`) from other databases. See *`Core.Records` / `Core.RecordFields`* above.
268
268
  10. **A fan-out `Client/` file must never reference a `customRecordFieldId` or a client-specific label.** Unlike `Core.RecordFields` ids, `CustomRecordFields` ids are **per-client `AUTO_INCREMENT`** — the same numeric id names a **different field in every client database**. A `Client/` file that hardcodes one silently wires the wrong field for 29 of 30 tenants, and there is no error. The same applies to `ClientCustomRecordFieldSettings` label rows. Move anything touching a custom field into the client's own `Client_<Name>/` file, even when it is "the same change" for several clients. Worked example: the all-clients `inventory_units` table view shipped in `Client/2026-08-11a`, but Elite's four custom columns (`grade`, `chargingBrick`, `chargingCable`, `wipeStatus` via `c_deviceStatus`) and their labels shipped separately in `Client_Elite/`.
269
269
  11. **`ALTER TABLE … ADD COLUMN` appends at the END of the table — always add `AFTER <predecessor>` (or `FIRST`)** so the column lands in its slot per the field-order standard ([backend-php → Order of Fields](../../standards/backend-php.md)): FKs go immediately after `uuid`. Fix a misplaced column with `MODIFY COLUMN … AFTER <col>` (keeps the FK constraint). Keep the `_Model` field declaration order consistent with the column order.
270
+ - **Leading columns (2026-10-25):** every client record table leads `id, uuid, dtCreated, dtUpdated, createdByUserId, updatedByUserId`. Adopt on an existing table with **one guarded `ALTER` per table** (add each missing column `AFTER` its predecessor, plus the FK to `Users` `ON DELETE SET NULL`; skip columns/constraints that already exist; re-runnable). Reference: `Client/2026-10-25k` (36 tables). Spec: [backend-php → Audit-user stamps](../../standards/backend-php.md); behavior: [model-audit-user-stamps](../_underscore/features/model-audit-user-stamps.md).
270
271
  12. **UUID literals are fresh random v4, never patterned; `Core.RecordFields` inserts set `isIdentifier` (`1` for `uuid`, `0` otherwise).** See [UUIDs](../../standards/backend-php.md) and rule 6.
271
272
  13. **Fan-out lookup seed (seeding baseline rows into every `Client_*` DB) = one guarded, re-runnable batched insert:** `INSERT INTO t (…) SELECT … FROM (SELECT <lits> UNION ALL SELECT …) AS src LEFT JOIN t existing ON existing.<naturalKey> = src.<naturalKey> WHERE existing.id IS NULL;`
272
273
  - Guard on the natural key (slug/name) backed by a real UNIQUE key: re-run is a no-op and a tenant that already has the rows keeps its own uuids.
@@ -409,7 +410,7 @@ The extra `SELECT … FROM ( … ) existing` layer both dodges error 1093 and pi
409
410
  ## Gotchas
410
411
 
411
412
  - **A successful local run is never proof a migration is production-safe.** Locally all DB families share one endpoint, so a cross-cluster reference resolves; in prod it dies. And per-machine MySQL server settings (`collation_server`, `sql_mode`, `lower_case_table_names`, `thread_stack`) let bad SQL pass on one machine and fail on another — see *Bulk data loads* and the [local-vs-prod parity workflow](workflows/local-vs-prod-mysql-config-parity.md).
412
- - **Letting `AUTO_INCREMENT` assign a `Core.Records`/`Core.RecordFields` id fails silently** — no error, just environments that drift apart and every hardcoded reference pointing at the wrong row. Always ask for the reserved id.
413
+ - **Letting `AUTO_INCREMENT` assign a `Core.Records`/`Core.RecordFields` id fails silently** — no error, just environments that drift apart and every hardcoded reference pointing at the wrong row. Always ask which id to start on (only that).
413
414
  - **A `Client/` fan-out file that hardcodes a `customRecordFieldId` wires the wrong field for 29 of 30 tenants with no error** (rule #10). Custom-field ids are per-client `AUTO_INCREMENT`; keep custom-field work in the tenant's own folder.
414
415
  - **An unguarded registration/seed insert can double-register** on a fresh environment (rule #7) — guard on the semantic identity, not the description.
415
416
  - **The `dbchanges2` folder name IS the target database.** Running a `Core/` file against a client DB fails as `Table 'Client_X.<Table>' doesn't exist` (files are unqualified by design) — which names a missing table, not the real mistake. See [targeting the right database](workflows/targeting-the-right-database.md).
@@ -417,6 +418,7 @@ The extra `SELECT … FROM ( … ) existing` layer both dodges error 1093 and pi
417
418
 
418
419
  ## Change history
419
420
 
421
+ - 2026-10-25 — Central ids: ask only which id to START on per table, number up from there; never ask for a count or id list (TRUE-83181). (jcardinal)
420
422
  - 2026-10-22 — Rule 16: key/index names max 64 chars (MySQL error 1059) (TRUE-82919). (jcardinal)
421
423
  - 2026-10-20 — Central ids can mean a different field per env (2632 local vs prod); guard inserts on recordId+field (TRUE-82934). (jcardinal)
422
424
  - 2026-10-18 — Core legacy-vs-new table collation gotcha (1267). TRUE-82907 (jcardinal)
@@ -6,7 +6,7 @@ project: Database Changes
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-23
9
+ updated: 2026-10-25
10
10
  owners: [jcardinal, apeterson, tcox, bala]
11
11
  files:
12
12
  - dbchanges2/Core/2026-10-23x - TRUE-82974 WorkflowActionsMenu.sql
@@ -622,7 +622,7 @@ After a seed that consumes ids, post **"next `<Table>.id` = N"** for teammates.
622
622
 
623
623
  These two are **not** like `Surfaces`/`SurfaceElements`/`Messages`. Platform-wide they are the **only** tables whose ids are **team-assigned by hand**, which is precisely what makes them safe to inline as literals in a `Client_*` file across the Core/Client cluster split. Three rules, in order:
624
624
 
625
- 1. **Ask the developer for the next free id.** The team maintains the running number and posts it after every id-consuming migration. That is the primary source — not a query, not the repo.
625
+ 1. **Ask the developer only which id to START on, per table, then number up from there** (never ask for a count or id list). The team maintains the running number and posts it after every id-consuming migration. That is the primary source — not a query, not the repo.
626
626
  2. **Never `AUTO_INCREMENT` a `Records` / `RecordFields` insert.** A generated id differs per environment and instantly breaks every literal that points at it.
627
627
  3. **Never infer the next id from `dbchanges2`** — **the repo lags production.** Rows get hand-applied ahead of (or instead of) a migration file, so the highest id in the repo is routinely *below* the real ceiling. Verify against **production** before running.
628
628
 
@@ -41,15 +41,25 @@ conventions.
41
41
 
42
42
  1. `id` (mandatory)
43
43
  2. `uuid` (mandatory)
44
- 3. Foreign Keys
45
- 4. Record Identifiers (references like order number)
46
- 5. Date/Time fields
47
- 6. Date fields
48
- 7. Flag/Boolean fields
49
- 8. ENUM-type fields (lists)
50
- 9. Char/Varchar/Integer/Decimal fields
51
- 10. Text/Blob fields
52
- 11. Custom fields
44
+ 3. `dtCreated`, `dtUpdated`, `createdByUserId`, `updatedByUserId` (audit stamps, in this order; client record tables, see below)
45
+ 4. Foreign Keys
46
+ 5. Record Identifiers (references like order number)
47
+ 6. Date/Time fields
48
+ 7. Date fields
49
+ 8. Flag/Boolean fields
50
+ 9. ENUM-type fields (lists)
51
+ 10. Char/Varchar/Integer/Decimal fields
52
+ 11. Text/Blob fields
53
+ 12. Custom fields
54
+
55
+ (Decided 2026-10-25, jcardinal.) Every 2.0 client record table leads with `id, uuid, dtCreated, dtUpdated, createdByUserId, updatedByUserId`. FKs go right after these.
56
+
57
+ #### **Audit-user stamps**
58
+
59
+ * `createdByUserId`, `updatedByUserId`: `INT UNSIGNED NULL`, FK to `Users` `ON DELETE SET NULL`, comment `'Client.Users record that created/last changed this record; set by the framework, NULL for machine writes'`.
60
+ * Model: opt in per model, client-database models only: `public $createdByUserId = self::FIELD_USER_CREATED;` / `public $updatedByUserId = self::FIELD_USER_UPDATED;`.
61
+ * Never write them by hand or from a payload. Behavior: [model-audit-user-stamps](../apps/_underscore/features/model-audit-user-stamps.md).
62
+ * New `Notes` tables: use `createdByUserId`, not a new `authorUserId`.
53
63
 
54
64
  **No `CHECK` constraints.** Never add MySQL `CHECK` constraints in schema changes; they caused problems with database backups. Validate allowed values in PHP. See [dbchanges2 rule 15](../apps/dbchanges2/architecture.md).
55
65
 
@@ -5,7 +5,7 @@ project: _Underscore
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-10-21
8
+ updated: 2026-10-25
9
9
  owners: [jcardinal, ajean, apeterson, mhammontree]
10
10
  files: []
11
11
  related:
@@ -173,12 +173,12 @@ framework-rules-cases.md.
173
173
  stored in prod `Core.Parameters` and managed in **Tools → Developers → Central IDs**. No other table on the platform may have a hardcoded
174
174
  `id`.
175
175
 
176
- **Inserting into them — ask first.** When a change inserts a row into `Core.Records` or
177
- `Core.RecordFields`, **stop and ask the developer for the next `id`**, then write it as an explicit
178
- literal. **Never let `AUTO_INCREMENT` assign it** — the column is still defined that way, but it is
176
+ **Inserting into them — ask for the start id only.** When a change inserts rows into `Core.Records` or
177
+ `Core.RecordFields`, **ask the developer only which `id` to START on for each table**, then number the
178
+ rows up from there as explicit literals. Never ask for a count or a list of ids. **Never let `AUTO_INCREMENT` assign it** — the column is still defined that way, but it is
179
179
  not the source of truth, and a value it picks silently drifts between environments and invalidates
180
180
  every hardcoded reference to that row. The `id` must be reserved by a human, so it cannot be
181
- guessed or derived — asking is mandatory.
181
+ guessed or derived — asking for the start id is mandatory.
182
182
 
183
183
  **Referencing them — hardcode the `id`.** Anywhere these rows are referenced, and anywhere a
184
184
  **foreign key** points at them (`recordId`, `recordFieldId`, and equivalents), use the numeric
@@ -0,0 +1,66 @@
1
+ ---
2
+ title: Internal Approvals Use the Approvals Table Set
3
+ framework: "2.0"
4
+ project: _Underscore
5
+ client: shared
6
+ type: standard
7
+ status: active
8
+ updated: 2026-10-25
9
+ owners: [jcardinal]
10
+ files:
11
+ - _underscore/Model/Client/Approval.php
12
+ - _underscore/Model/Client/ApprovalDecision.php
13
+ - _underscore/Model/Client/ApprovalTemplateStage.php
14
+ - _underscore/Model/Client/SalesOrder.php
15
+ - _underscore/Trait/Approvable.php
16
+ related:
17
+ - ../apps/_underscore/features/sales-order-denial-reason.md
18
+ - ../../clients/compass-usa/features/approval-decision-flow.md
19
+ - ../../clients/compass-usa/features/mr-ma-order-approval-and-status.md
20
+ - backend-php.md
21
+ ---
22
+
23
+ Rule: any approval by someone inside the client (manager, admin) is built on the Approvals table set with a calculated status, not a stored status column; open when adding or changing any approval (generic `_Trait_Approvable` path, e.g. user certifications).
24
+
25
+ ## Rules
26
+
27
+ 1. **Internal approval** = a person in our/the client's org must approve (manager, admin, reviewer). It MUST use the Approvals table set (`Approvals`, `ApprovalTemplates`, `ApprovalTemplateStages`, `ApprovalDecisions`, `ApprovalDecisionTypes`, plus `ApprovalTemplateStageAllowed{Users,Roles,Personas}`) in the client DB. Applies to every app and every record type.
28
+ 2. **Do not** store the approval state as a plain column (`status`, `isApproved`, `approvedBy`) on the record. Convert any existing one.
29
+ 3. The record's approval status is a **calculated field** (`FIELD_SQL`, `_status`-style) derived from `Approvals` + `ApprovalDecisions`, set to store the details. Decisions (who, when, `isApproved`, `note`) live on `ApprovalDecisions`.
30
+ 4. **External approval** = a third party outside the org approved it (e.g. a certification issuer). A plain stored status field is fine. Keep it separate from the internal approval.
31
+ 5. Both can exist on one record (e.g. user certification: issuer-verified = stored field; manager sign-off = Approvals table).
32
+
33
+ ## Pattern (from Compass USA sales orders)
34
+
35
+ - `Approvals` row links a record: `approvalTemplateId` + `recordId` (Core.Records id; sales orders = 14) + `primaryKeyId` (the record's id). Unique on those three. `_Model_Client_Approval`.
36
+ - `ApprovalTemplates` (`isActive`, `recordId`) hold ordered `ApprovalTemplateStages` (`step`, `name`). Compass: stage 1 "Pending Initial Approval" (admin), stage 2 "Pending Approval" (manager).
37
+ - One `ApprovalDecisions` row per stage per approval: `assignedToUserId`, `decidedByUserId`, `isApproved` (NULL pending, 1 approved, 0 denied), `approvalDecisionTypeId` (system decision, e.g. auto-approve), `dtDecision`, `note` (denial reason). `_Model_Client_ApprovalDecision`.
38
+ - Status is a calculated field: `_Model_Client_SalesOrder::_status()` (`Model/Client/SalesOrder.php`) subselects `Approvals` -> `ApprovalTemplates` -> `ApprovalTemplateStages` -> `ApprovalDecisions`: any `isApproved = 0` -> canceled; lowest-step stage with no decision or `isApproved IS NULL` -> that stage's pending status (via `SalesOrderStatuses.pendingApprovalTemplateStageId`); all approved -> move on to fulfillment statuses.
39
+ - Who may decide a stage: `_Model_Client_ApprovalTemplateStage` (allowed users/roles/personas + strict `assignedToUserId` match). Shared by every client; do not widen it for one client.
40
+ - Approval rows and decisions are created/driven by model interceptors (`postPost`/`postPut`), e.g. `_Model_Compass_SalesOrder`, `_Model_Compass_ApprovalDecision`; see [approval-decision-flow](../../clients/compass-usa/features/approval-decision-flow.md).
41
+
42
+ ## Example
43
+
44
+ Compass USA sales orders: [approval-decision-flow](../../clients/compass-usa/features/approval-decision-flow.md), [mr-ma-order-approval-and-status](../../clients/compass-usa/features/mr-ma-order-approval-and-status.md), [sales-order-denial-reason](../apps/_underscore/features/sales-order-denial-reason.md).
45
+
46
+ ## Generic path (`_Trait_Approvable`) — use this for new records
47
+
48
+ Sales orders keep their own flow. Every new approvable record uses the trait (`Trait/Approvable.php`). First user: user certifications (record 392; `dbchanges2` `Client/2026-10-25g`, `Client/2026-10-25h`, `Core/2026-10-25d`; TRUE-83181).
49
+
50
+ - **Opt in:** `use _Trait_Approvable;` + `const RECORD_ID` (the `Core.Records.id`) + `protected function isApprovalRequiredOnInsert(): bool` (the model's own rule). A model with its own `save()` writes `use _Trait_Approvable { save as protected saveApprovable; }` and ends its `save()` with `return $this->saveApprovable();`. A model with its own `delete()` must call the trait's too.
51
+ - **Status:** the trait adds calculated fields `_approvalStatus` (PENDING / APPROVED / REJECTED) and `_approvalUuid`. Both need a `Core.RecordFields` row. `_Model_Client_Approval::statusSql(recordId, pkColumn)` derives status from ACTIVE templates only: any decision `isApproved = 0` -> REJECTED; any stage with no decision or NULL -> PENDING; else APPROVED (also when the row has no Approvals row).
52
+ - **Getting an approval:** on insert, when `isApprovalRequiredOnInsert()` is true, the trait's `save()` calls `_Model_Client_Approval::createForRecord(['recordId', 'primaryKeyId'])` in the same request. It uses the lowest-id ACTIVE template of that record (no row if none). One approval per row (unique on recordId + primaryKeyId), so a second active template never gets rows.
53
+ - **Guard:** a request user cannot write or delete the Approvals row of an approvable record (`isApprovableRecord()`; `isInternalWrite` lets only `createForRecord` through). Sales orders and no-user callers are not affected.
54
+ - **Who may decide:** the client posts to `POST /approval-decisions` (uuid from `_approvalUuid`). `_Model_Client_ApprovalDecision::save()` validates ONLY for approvable (trait) records: signed-in user, `isApproved` given, stage belongs to the active template, not already rejected, stage not already decided, lower steps approved, and user allowed by the stage (`ApprovalTemplateStageAllowedUsers/Roles/Personas`, or strictly `assignedToUserId`). The server sets `decidedByUserId` and `dtDecision`. Other records skip all of this.
55
+ - **UI check:** `_Model_Client_ApprovalTemplateStage::_allowDecide(approvalUuid:<uuid>)` returns 1/0 for the signed-in user; it mirrors the save rules.
56
+
57
+ ## How to build a new one
58
+
59
+ 1. Add a `dbchanges2` file: `ApprovalTemplates` + `ApprovalTemplateStages` + allowed users/roles/personas rows for the new `recordId` (see [backend-php](backend-php.md) for SQL file rules), and the `Core.RecordFields` rows for the two calculated fields.
60
+ 2. Add the trait to the model as above.
61
+ 3. Remove any old stored internal-approval column after data is migrated.
62
+
63
+ ## Gotchas
64
+
65
+ - **Deactivating a template turns every PENDING record on it into APPROVED.** Status is derived from the ACTIVE template, so no active template = APPROVED. Add a new template instead of switching one off while rows are pending.
66
+ - **Deferred ACL hole (Desk question `sd`):** in `Client_True`, the Base role can edit approval config (records 173-178), and a Base user can add an APPROVED holding for a colleague. The developer deferred the ACL fix; see the Desk docs questions file (question `sd`).
@@ -22,7 +22,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
22
22
 
23
23
  ## 2.0 framework
24
24
 
25
- - **_underscore** (_Underscore) _(framework core)_ — 116 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
25
+ - **_underscore** (_Underscore) _(framework core)_ — 117 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
26
26
  - **worker2** (Worker) — 94 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
27
27
  - **api2** (API) — 28 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
28
28
  - **dbchanges2** (Database Changes) _(framework core)_ — 23 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -214,6 +214,7 @@ When a step-2 (Manager) decision is reassigned, `_swapManagerEmailAddress()` upd
214
214
  - 2026-10-06 — Recorded that no delegate + no supervisor saves an order with no step-2 decision, and that the commerce cart now blocks it (TRUE-82423). (bala)
215
215
 
216
216
  ## Related
217
+ - [Internal Approvals Use the Approvals Table Set](../../../2.0/standards/internal-approvals.md) — team rule: this table-set + calculated-status approach is the model for every internal approval in every app.
217
218
  - [Stranded Approval Reassignment](stranded-approval-reassignment.md) — repair half: repointing approvals already stamped with a dead duplicate `Users` id.
218
219
  - [Compass MR/MA Order Auto-Approval & Status Gate](mr-ma-order-approval-and-status.md) — auto-approval + `_status` gating half (on `_Model_Compass_SalesOrder`).
219
220
  - [Compass Canada](../../compass-canada/profile.md) — uses this identical flow; the runtime `Compass_Canada` branch drives its EN/FR notification localization.
@@ -84,6 +84,9 @@ writes, and agents until the session has a ticket. These replaced `/kickoff`, `/
84
84
  other task id is blocked), and never to Has Code Changes (worker2 owns it). It also blocks task
85
85
  creation, deletion, moves, links, docs, chat, time tracking and `update_comment` (no task id
86
86
  to check).
87
+ - Reads are always open: `get_*`, `search*`, `filter_*`, `list_*`, `find_*`, and `download_*`
88
+ (task and doc-page attachments — ticket screenshots are often the whole spec; team rule
89
+ 2026-10-24, jcardinal). The download URL is short-lived and single-use: fetch it once, at once.
87
90
  - No permission prompt for `clickup_update_task` or `clickup_create_comment` (since 2026-10-09). Only `Bash(git push *)` stays in `permissions.ask`. `npx toga-ai`
88
91
  strips the old ClickUp rules from a dev's existing `permissions.ask` (see harness distribution).
89
92
  - Use only the `clickup` MCP server (`mcp__clickup__clickup_*`). The old read-only
@@ -208,6 +211,7 @@ All tested 2026-10-08:
208
211
 
209
212
  - 2026-10-13 — `/commit` now pushes the ticket branch; `/finish`'s "finished" yes covers commit,
210
213
  push, and PR with no extra asks; Claude may switch to the ticket branch in normal work. (jcardinal, TRUE-82715)
214
+ - 2026-10-24 — `start-gate` allows `clickup_download_*` (task / doc-page attachments) as reads. (jcardinal, TRUE-83181)
211
215
  ## Related
212
216
 
213
217
  - [Ticket-branch creation safety](./ticket-branch-creation-safety.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.1145",
3
+ "version": "1.0.1147",
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",
@@ -106,8 +106,9 @@ const CLICKUP_URL_PREFIX = 'https://app.clickup.com/';
106
106
  const INTERNAL_ID_RE = /^[A-Za-z0-9]+$/;
107
107
  const MAX_UNWRAP_DEPTH = 6;
108
108
  const OLD_CLICKUP_SERVER_RE = /^claude_ai_ClickUP_for_Dev$/i;
109
- /* get_* covers get_operators and get_schema. download_* is a read but is not needed — blocked. */
110
- const CLICKUP_READ_RE = /^(get_.+|search|search_.+|filter_.+|list_.+|find_.+|resolve_assignees)$/;
109
+ /* get_* covers get_operators and get_schema. download_* covers task and doc-page attachments
110
+ (ticket screenshots are often the whole spec — team rule 2026-10-24, jcardinal). */
111
+ const CLICKUP_READ_RE = /^(get_.+|search|search_.+|filter_.+|list_.+|find_.+|download_.+|resolve_assignees)$/;
111
112
  /* Any field of the session ticket (team rule 2026-10-15). Not `task_type`: Task Type is a custom field here. */
112
113
  const UPDATE_TASK_KEYS = new Set([
113
114
  'task_id', 'workspace_id', 'status', 'name', 'custom_fields', 'markdown_description', 'priority',