toga-ai 1.0.1074 → 1.0.1076

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.
@@ -4,13 +4,14 @@
4
4
  |-----|---------|
5
5
  | [Proposed — git-sourced base+overlay JSON authoring for the Surface layer](architecture/surface-authoring-proposal.md) | Draft proposal (NOT implemented) to move Surface authoring off hand-written SQL onto git-sourced base+overlay JSON; open only for that design rationale. |
6
6
  | [_underscore Framework Architecture](architecture.md) | The 2.0 `_underscore` PHP backend framework — DB abstraction, ORM models, routing, ACL, Surface, multi-tenant clusters; open for how any 2.0 app backend works. |
7
+ | [ACL Capabilities (roles = collections of capabilities; dual-read transition; role delete)](features/acl-capabilities.md) | Client-DB ACL layer between roles and permissions (Capabilities, Roles_Capabilities, capabilityId on ACL rows); open before writing any client ACL grant, readin |
7
8
  | [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not |
8
9
  | [Address Uniqueness Normalization (unit identifier + 5-digit ZIP comparison)](features/address-uniqueness-normalization.md) | Two reusable normalization primitives for any "one X per physical address" rule; open when comparing addresses for uniqueness. |
9
10
  | [Address Validation (carrier waterfall + validateAddress scripted endpoint)](features/address-validation.md) | US address validation via a carrier waterfall (USPS → FedEx → UPS) exposed as a scripted endpoint; open when validating/normalizing addresses. |
10
11
  | [_ApiRequest — JSON encode/decode & api-logging behavior](features/apirequest-json-content-type.md) | `_ApiRequest`, the 2.0 outbound HTTP client — JSON encode/decode, retry, and api-logging traps; open before calling it or reading `Logs.Api`. |
11
12
  | [Assortment Name Translation (AssortmentTranslations sidecar)](features/assortment-name-translation.md) | Serves Assortment names in multiple languages via an `AssortmentTranslations` sidecar table on the metadata-driven translation layer; open when translating asso |
12
13
  | [Asynchronous Query Execution (writes-only, via Worker)](features/async-query-execution.md) | Run a slow WRITE asynchronously via the Worker tier so a long request write cannot hold a stale connection and hit "MySQL server has gone away"; open before usi |
13
- | [Record numbering (platform-wide FIELD_AUTOINCREMENT: Core.Records prefix + counter, scrambled)](features/autoincrement-record-numbering.md) | ORM record numbers (`SA1000000`, `AJ1419`, `CL519`) from one platform-wide counter per record type in `Core.Records`; open when adding a numbered model/prefix, |
14
+ | [Record numbering (FIELD_AUTOINCREMENT: per-client sequential numbers from RecordNumbers)](features/autoincrement-record-numbering.md) | ORM record numbers (`SA139197`, `AJ100000`, `CL146`) counted in order per client from a `RecordNumbers` table; open when adding a numbered model/prefix, debuggi |
14
15
  | [Calculated-column filter options (`<field>FilterOptions` model static → table meta)](features/calculated-column-filter-options.md) | Lets a calculated (`FIELD_SQL`) table column supply its own filter dropdown options via a `<field>FilterOptions` model static; open when adding a data-driven co |
15
16
  | [FIELD_SQL calculated fields — the underscore-prefix + same-name-method contract](features/calculated-sql-fields.md) | A `FIELD_SQL` (calculated) field on a `_Model` is bound by a **two-part contract that `_Model` enforces by throwing at model-construction time**, not by convent |
16
17
  | [Carrier Shipping Labels (UPS/FedEx) & NetSuite Item Fulfillment](features/carrier-shipping-labels.md) | Backend mechanics behind Fulfill & Ship: buying/persisting a carrier label (UPS/FedEx) and creating the NetSuite Item Fulfillment; open for label, reprint, retu |
@@ -0,0 +1,84 @@
1
+ ---
2
+ title: ACL Capabilities (roles = collections of capabilities; dual-read transition; role delete)
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-10-18
10
+ owners: [jcardinal]
11
+ files:
12
+ - _underscore/Model/Client/Capability.php
13
+ - _underscore/Model/Client/Roles/Capability.php
14
+ - _underscore/Model/Client/Role.php
15
+ - _underscore/Model/Client/AclRecordPermission.php
16
+ - _underscore/Model/Client/AclFieldPermission.php
17
+ - _underscore/Model/Client/AclCustomFieldPermission.php
18
+ - api2/Component/Api/V2/V2.php
19
+ - dbchanges2/Client/2026-10-18b - TRUE-82880 Capabilities.sql
20
+ - dbchanges2/Core/2026-10-18e - TRUE-82880 CapabilitiesCore.sql
21
+ related:
22
+ - acl-permission-chain.md
23
+ - record-hard-delete.md
24
+ - ../../api2/features/api-key-authentication-and-scoping.md
25
+ ---
26
+ Client-DB ACL layer between roles and permissions (Capabilities, Roles_Capabilities, capabilityId on ACL rows); open before writing any client ACL grant, reading ACL rows in SQL, deleting a role, or dropping `roleId`.
27
+
28
+ ## Summary
29
+
30
+ A **Capability** (`Client_*.Capabilities`, Core.Records **409**) is a per-client named set of
31
+ `AclRecordPermissions` (+ their logic groups), `AclFieldPermissions` and `AclCustomFieldPermissions`
32
+ rows, linked by a new `capabilityId` column. A **Role** is a set of capabilities
33
+ (`Roles_Capabilities`, Records **410**). Access = **union** of all capabilities of all the user's
34
+ roles. No per-role or per-user overrides. Authoring UI = Hub (later). Built TRUE-82880.
35
+
36
+ **Not capability-keyed (stay role-keyed):** `AclActionPermissions`, `AclRecordScripts`,
37
+ `AclCustomRecordScripts`, `Users_Roles`, `Apis_Roles`. **Core DB ACL stays role-based** — capabilities
38
+ exist only for `aclDatabase = 'CLIENT'` records.
39
+
40
+ ## How it works
41
+
42
+ - **JWT unchanged** — it still carries role ids. Per request, `V2::buildLookups()` expands
43
+ role → capability ids (`buildClientRoleIdsByCapabilityId`).
44
+ - **Dual-read (transition):** client ACL rows load with
45
+ `capabilityId IN (<caps>) OR (capabilityId IS NULL AND roleId IN (<roles>))`
46
+ (`buildClientAclDualReadCondition`). So legacy role-only rows still work.
47
+ - **Lookup buckets unchanged:** `[db][roleId][recordId]`, keyed by permission id, so a row reached
48
+ through two roles is not counted twice. `resolveClientAclRowRoleIds` maps a capability row back to
49
+ every role holding that capability.
50
+ - **Field permissions merge writable-wins:** same field from two capabilities → writable if any says writable.
51
+ - **`AclFieldPermissions.appId`:** NULL = all apps; otherwise must equal the request's app.
52
+ - **Migration** (`Client/2026-10-18b`, dynamic, re-runnable): one capability per role, named after
53
+ the role, linked only to that role; existing ACL rows get its `capabilityId`. `roleId` stays filled
54
+ and becomes nullable.
55
+ - **`roleId` drop is later:** its own forward `dbchanges2` file, after a release. Do the gotchas
56
+ below first.
57
+
58
+ ### Role delete (`_Model_Client_Role::preDelete`, PRE DELETE interceptor on record 274)
59
+
60
+ - Gated only by `allowDelete` on `roles` ACL. Hard delete. **No special system-role logic.**
61
+ - **Blocked (400)** while any `Users_Roles` or `Apis_Roles` row holds the role.
62
+ - Uses [record hard delete](record-hard-delete.md) `relatedRows()`: deletes `Roles_Capabilities`
63
+ links and role-keyed rows (`AclActionPermissions`, `AclRecordScripts`, `AclCustomRecordScripts`,
64
+ `Announcements_Roles`, `ApprovalTemplateStageAllowedRoles`, `SurfaceOverrides`).
65
+ - Then `removeAclRows()`: legacy rows (`capabilityId IS NULL`) are deleted (record permissions with
66
+ their logic groups first); capability rows are kept with `roleId = NULL`. **Capabilities are never
67
+ deleted with a role.**
68
+
69
+ ## Gotchas
70
+
71
+ - **⚠ Before the `roleId` drop: worker2 `Worker/Sync/TrackingNumbers.php` (~L460) still joins
72
+ `AclRecordPermissions` / `AclFieldPermissions` on `roleId`.** Move it to the capability path first.
73
+ Grep every repo for raw-SQL reads of `roleId` on these three tables too.
74
+ - **⚠ New unique keys with NULL `appId` / `indirectRecordId` do not block duplicates** (MySQL treats
75
+ NULLs as distinct). Today the old role-based keys still protect; after the `roleId` drop they do
76
+ not — guard inserts with `NOT EXISTS`.
77
+ - **Writers (Hub, migrations) must write `roleId = NULL` on new capability rows.** A filled `roleId`
78
+ makes the row look legacy-shaped and keeps it alive on the old key.
79
+ - **⚠ Open (security, pre-existing):** the client `Base` role (every user holds it) has full CRUD on
80
+ `user-roles`, `roles`, `acl-field-permissions`, `acl-custom-field-permissions` — a user can grant
81
+ themselves more access. Developer decision: fix when Hub is built.
82
+
83
+ ## Change history
84
+ - 2026-10-18 — Created: Capabilities layer, dual-read, writable-wins field merge, role delete rules (TRUE-82880). (jcardinal)
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-06
9
+ updated: 2026-10-18
10
10
  owners: ["jcardinal", "mhammontree", "tcox", "bala", "apeterson", "rgirish"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -28,6 +28,11 @@ inserting only an `AclRecordPermissions` row is the single most common mistake
28
28
  silently does nothing without the rest of the chain. A complete grant requires **all four** tables,
29
29
  plus `AclFieldPermissions` for the fields to be readable/writable.
30
30
 
31
+ **Client-DB ACL rows now belong to a Capability** (`capabilityId`), and roles are sets of
32
+ capabilities; legacy `roleId`-only rows still work during the transition (dual-read). Read
33
+ [ACL Capabilities](acl-capabilities.md) before writing or querying client ACL rows. Core DB ACL is
34
+ still role-based.
35
+
31
36
  ## The complete chain (all four are required)
32
37
 
33
38
  ```
@@ -412,7 +417,8 @@ first (see
412
417
  - **Two separate role spaces with different ids and names:** Core roles (`Core.Roles`: Public, Integrations, Super User, Base) and per-tenant client roles (`Client_<X>.Roles`). Ids are not comparable across spaces.
413
418
  - `AclRecordPermissions.roleId` / `AclFieldPermissions.roleId` are read in the record's `aclDatabase` role space: CORE record → Core role id; CLIENT record → client role id. `roleId=1` is Core Public on a Core record but a client role on a client record — always filter by the record's `aclDatabase`.
414
419
  - **Link:** `Client.Roles.coreRoleId`. At login the user's client roles map to Core roles through their distinct non-null `coreRoleId`s, set as `id.core.roles`. CORE records filter on the user's Core roles; CLIENT records on their client roles.
415
- - **No inheritance:** no parent role / hierarchy; roles are used verbatim by the resolver.
420
+ - **No inheritance:** no parent role / hierarchy; roles are used verbatim by the resolver. Client roles
421
+ reach client ACL rows through their capabilities (union) — see [ACL Capabilities](acl-capabilities.md).
416
422
  - **Public = read-only baseline every authenticated user inherits.** Public is MERGED (additively) into the effective core roles at token build, `processRoutePairs`, and `buildLookups`; without the merge the mapped `coreRoleId`s overwrite it and a role like Core Base cannot read Core metadata (e.g. `Core.Clients.uuid`). Keep Public read-only: never grant it write on Core metadata.
417
423
  - Related: no backend ACL cache in 2.0 — a stale 403 is the UI cache or ACL logic, never a backend cache.
418
424
 
@@ -925,11 +931,13 @@ hardcoded `Core.RecordFields` id literals instead of a subselect.
925
931
  NULL plus `sqlExpression='1'` means no row scoping whatsoever (see the section above).
926
932
  - [ ] **No business-critical column is `isWritable = 1` for `Base`** unless customers are genuinely
927
933
  meant to write it via plain CRUD.
928
- - [ ] For `aclDatabase = 'CLIENT'` records: rows go in client DB(s); resolve `roleId` by subselect.
934
+ - [ ] For `aclDatabase = 'CLIENT'` records: rows go in client DB(s); resolve `roleId` by subselect;
935
+ set `capabilityId` (see [ACL Capabilities](acl-capabilities.md)).
929
936
  - [ ] The field is declared on the **generated** `_underscore/Model/Client/<Name>.php` (else `EO-1`),
930
937
  and every repo is on the **same branch** so the generated model matches the DB.
931
938
 
932
939
  ## Change history
940
+ - 2026-10-18 — Client ACL rows now carry `capabilityId`; linked the new Capabilities doc (TRUE-82880). (jcardinal)
933
941
  - 2026-09-21 — Named the mechanism behind the half-built chain: `_Model_Core_AclRecordPermission::buildSqlExpression`
934
942
  returns **`'(0)'`** with no logic group, so `allowRead = 1` alone denies everything (second symptom: 403 listing
935
943
  `aclRecordPermissionIds` with `[null, null, null]`). Added that a `CLIENT` record's grant must exist in every
@@ -1,22 +1,22 @@
1
1
  ---
2
- title: "Record numbering (platform-wide FIELD_AUTOINCREMENT: Core.Records prefix + counter, scrambled)"
2
+ title: "Record numbering (FIELD_AUTOINCREMENT: per-client sequential numbers from RecordNumbers)"
3
3
  framework: "2.0"
4
4
  repo: _underscore
5
5
  project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-13
9
+ updated: 2026-10-18
10
10
  owners: ["bala", "rgirish", "jcardinal"]
11
11
  files:
12
12
  - _underscore/Model.php
13
13
  - _underscore/Database.php
14
14
  - _underscore/Model/Client/SalesOrder.php
15
- - _underscore/Model/Client/ServiceRequest.php
16
- - _underscore/Model/Client/TransferOrder.php
17
15
  - _underscore/Model/Compass/Canada/SalesOrder.php
18
16
  - _underscore/Model/Core/Client.php
19
- - dbchanges2/Core/2026-10-13d - RecordNumbering.sql
17
+ - dbchanges2/Core/2026-10-18a - RecordNumbers.sql
18
+ - dbchanges2/Client/2026-10-18a - RecordNumbers.sql
19
+ - dbchanges2/Client_CompassCanada/2026-10-18a - RecordNumbers SAC prefix.sql
20
20
  related:
21
21
  - ../architecture.md
22
22
  - ../../dbchanges2/architecture.md
@@ -25,39 +25,51 @@ related:
25
25
  - ../../worker2/features/netsuite-salesorder-outbound-push.md
26
26
  ---
27
27
 
28
- ORM record numbers (`SA1000000`, `AJ1419`, `CL519`) from one platform-wide counter per record type in `Core.Records`; open when adding a numbered model/prefix, debugging a 1062 on `number`, or touching `formatRecordNumber`.
28
+ ORM record numbers (`SA139197`, `AJ100000`, `CL146`) counted in order per client from a `RecordNumbers` table; open when adding a numbered model/prefix, debugging a 1062 or missing-row error on `number`, or seeding a new client.
29
29
 
30
30
  ## Summary
31
31
 
32
- A model declares `public $number = [self::FIELD_AUTOINCREMENT];` — nothing else (the old `FIELDOPT_AUTOINCREMENT_PREFIX/FIRSTNUMBER/SUFFIX` options, the `MAX()+1` path and the per-tenant `Parameters` counter / PR `_underscore#756` are gone; PR 756 must NOT be merged). Prefix and counter live in `Core.Records`, so numbers are unique across all clients. TRUE-82715.
32
+ A model declares `public $number = self::FIELD_AUTOINCREMENT;` — nothing else. Prefix and next number live in a `RecordNumbers` row (data, not model options). Numbers are per client: the same number (e.g. `TK100005`) can exist for two clients. TRUE-82715.
33
+
34
+ Removed: `FIELDOPT_AUTOINCREMENT_PREFIX` / `_FIRSTNUMBER` / `_SUFFIX`, the `MAX()+1` path, and the per-tenant `Parameters` counter (PR `_underscore#756` — never merge). The 2026-10-13 platform-wide scrambled design (`Core.Records.numberPrefix/nextNumber`, multiplier) was dropped before release. App repo `_underscore/CLAUDE.md` (~line 227) still lists the removed options — stale.
33
35
 
34
36
  ## How it works
35
37
 
36
- 1. **Columns:** `Core.Records.numberPrefix CHAR(2) UNIQUE`, `nextNumber BIGINT UNSIGNED`. No `RecordFields` rows on purpose → not editable through the API.
37
- 2. **Row lookup:** `_Model` walks its class + parents against `Records.model` (stored with leading backslash, UNIQUE). Client overrides (`_Model_Compass_Canada_SalesOrder`) use the base row (SA). No row / NULL prefix / NULL counter → plain `Exception` (fail loud).
38
- 3. **Allocate:** `UPDATE Records SET nextNumber = LAST_INSERT_ID(nextNumber + 1) WHERE id = X` on `_Database::DB_CORE_SEQUENCE` (`'CoreSequence'`), committed at once; counter = insertId − 1. CoreSequence follows `DB_CORE` when registered (api2), else falls back to the config path of the physical Core DB (worker2 prod, saml). Re-registering Core with other settings closes the CoreSequence link.
39
- 4. **Format:** `prefix + formatRecordNumber(counter)` — no hyphen, no check digit. d = digit count of counter, c = offset within that width: `shown = 10^(d-1) + (c * 387419) % (9*10^(d-1))`. Bijection per width (multiplier coprime to 2, 3, 5) → no uniqueness check or retry loop; same digit count as the counter; length grows on its own.
40
- 5. **Start counters** (dbchanges2 seed): SA(14), SR(35), TK(99), ET(191) at 1000000 — 7 digits so they never equal old 6-digit numbers; PU(17), TR(312), RC(78), FF(28), AJ(154), CS(222) at 1000; CL(51) at 100 + client count. `Core.Clients.number VARCHAR(16) NOT NULL UNIQUE` added + backfilled in id order (RecordFields 2740).
41
- 6. **Hand-set number wins** (counter untouched). 1062 retry up to 5 only when the dup is the number this save generated. `forSearching` with null number skips the field (keeps `load()`'s empty-WHERE guard).
38
+ 1. **Table `RecordNumbers`** (`id, uuid, dtCreated, dtUpdated, recordId INT UNSIGNED UNIQUE, prefix VARCHAR(8) UNIQUE, nextNumber INT UNSIGNED`). `recordId` = `Core.Records.id`; `nextNumber` = next number to issue.
39
+ - One in **every client DB** — no FK (Core is another cluster).
40
+ - One in **Core** — real FK to `Records` — for Core-based records (`Core.Clients`, recordId 51, `CL`).
41
+ 2. **Which table:** from the model's `const DATABASE`: `DB_CORE` → `Core.RecordNumbers`; `DB_CLIENT` → that client's; anything else throws.
42
+ 3. **Which row:** walk class + parents against `Core.Records.model` (stored with leading backslash), cached per class. Client overrides (`_Model_Compass_Canada_SalesOrder`) use the base row. No row → plain `Exception` (fail loud, no self-seeding).
43
+ 4. **Allocate:** `UPDATE RecordNumbers SET nextNumber = LAST_INSERT_ID(nextNumber + 1) WHERE recordId = X` on a separate sequence connection, committed at once (`_Database::DB_CORE_SEQUENCE` `'CoreSequence'` / `DB_CLIENT_SEQUENCE` `'ClientSequence'`). Number = insertId − 1; prefix read on the same connection.
44
+ 5. **Sequence connections** follow their source alias on `register()`; dropped/closed when the source is re-pointed or re-registered with other settings (a stale ClientSequence never uses another tenant's counter). `connect()` falls back to the source DB when Core/Client is not registered by alias (worker2 prod).
45
+ 6. **Format:** `prefix . nextNumber` — counting in order, no padding, no hyphen. Prefix validated in PHP: 1–8 letters A–Z except O, I, B (look like 0, 1, 8); L allowed. No CHECK constraint ([dbchanges2 rule 15](../../dbchanges2/architecture.md)).
46
+ 7. **Hand-set number wins** (counter untouched). `forSearching` with null number skips the field. 1062 retry up to 5, only when the dup is the number this save generated.
47
+
48
+ **Prefixes:** client DBs SA(14), PU(17), FF(28), SR(35), RC(78), TK(99), AJ(154), ET(191), CS(222), TR(312); Core CL(51). Per-client override allowed (Compass Canada sales orders = `SAC`).
49
+
50
+ **Seeds (continue each client's old series under the new prefix):**
51
+ - `Client/` file: `nextNumber = GREATEST(100000, max(old-prefix number)+1)`. Old → new: SA→SA, P→PU, F→FF, SR→SR, IR→RC, TK→TK, IA→AJ, ET→ET, CA→CS, TA→TR (TA was a likely mistake). Old numbers matched with `REGEXP_LIKE(number, '^OLD[0-9]+$', 'c')` — case-sensitive; skips NetSuite numbers and free text like `REPL SA123681`.
52
+ - `Core/` file: `Clients.number VARCHAR(16) NOT NULL UNIQUE`, backfilled CL100, CL101… in id order; CL `nextNumber` = 100 + client count; RecordFields 2740 = `clients.number`.
53
+ - `Client_CompassCanada/` file (runs after `Client/`): sales orders `SAC`, continuing after the highest SAC number.
42
54
 
43
- **Format rules (jcardinal):** 2-letter prefix, stored in `Core.Records` not the model; letters O, I, B banned (look like 0, 1, 8), L allowed; validate in PHP (no CHECK — see [dbchanges2 rule 15](../../dbchanges2/architecture.md)). TA → TR.
55
+ **Why per-client and in order (jcardinal):** clients spot gaps in their own series (Compass USA); clients want own prefixes (Compass Canada); bigger = newer. Desk shows the client column next to the number — number display unchanged.
44
56
 
45
- **Rejected:** plain counter (type unreadable); numeric million bands (break at 1M); client id/date in number (long, leaks); check digit; Crockford base32 (hard to read aloud); random + exists-check loop; per-type multiplier column (edits break numbers); `numberMinDigits` column (start counter sets length). Block allocation of 1,000 deferred — one write per create is fine for now.
57
+ **Turned down:** platform-wide counter (every client sees constant gaps; Core write per create); scrambled / multiplier numbers (confuse the business; little security gain — uuid is the API id, ACL protects records); check digit; Crockford base32; client id or date in number; million bands; plain counter with no prefix.
46
58
 
47
59
  ## Gotchas
48
60
 
49
- - **⚠ NEVER change `AUTOINCREMENT_MULTIPLIER = 387419`** — every issued number depends on it.
50
- - **New numbered model needs a `Core.Records` row with `numberPrefix` + `nextNumber`** or creates throw. `Model/Client/Scope.php` (Desk branch TRUE-82615, prefix `'SC-'`) has no row: on merge make it plain `FIELD_AUTOINCREMENT` + seed a Records row prefix `SC`.
51
- - **Local DB with an old `Clients.number` (CL1…)** → the migration's ADD COLUMN fails; fix by hand locally. Prod had no such column though `_Model_Core_Client` declared one.
61
+ - **New numbered model needs a `RecordNumbers` row in every client DB** (or Core for `DB_CORE` models), else creates throw. Scopes: no `Core.Records` row yet; `Model/Client/Scope.php` exists only on Desk branch TRUE-82615 — on merge make it plain `FIELD_AUTOINCREMENT` + seed a row.
62
+ - **New client DB** must get `RecordNumbers` rows from the `Client/` blank seed.
63
+ - **Gaps only come from failed saves.** A gap = a failed create → check `Logs.Api` / `Logs.Issue`.
52
64
  - **SA / TR numbers are placeholders** — NetSuite `tranId` overwrites them on write-back (accepted). Never key a match on `number`; pushes key `externalId` on uuid.
53
- - **Old `MAX()+1` race (why this exists):** concurrent creates got one number → 1062 → HTTP 400 `EV-10`, record lost (Prudential SR, TRUE-82209). Staggering callers did not fix it.
54
- - **Gaps are possible** (a rolled-back request still used its number).
55
- - **Reproducing a race needs distinct rows per call** (different contact + serial) and a gate-file release ([V2 request retry](../../api2/features/v2-deadlock-retry.md)); sandbox-dev `max_connections = 90` → keep bursts ~10.
65
+ - **Old `MAX()+1` race (why this exists):** concurrent creates got one number → 1062 → HTTP 400 `EV-10`, record lost (Prudential SR, TRUE-82209).
66
+ - **Core seed fails on a local stray `RecordFields` row** for `clients.number` (one local had id 2598, not in prod/dev-sandbox/repo) → guarded insert of 2740 hits unique `recordId_field`. Delete the stray row locally; the file fails loud on drift on purpose.
67
+ - **Reproducing a race needs distinct rows per call** (different contact + serial) and a gate-file release ([V2 request retry](../../api2/features/v2-deadlock-retry.md)); sandbox-dev `max_connections = 90` → bursts ~10.
56
68
  - **Run `php -l Model.php Database.php` after merge conflicts on env branches** — a past merge dropped `_Database::$_aliases` → beta outage.
57
69
 
58
70
  ## Verification
59
71
 
60
- 2026-10-13 local (Client_Elite): single create AJ1419 for counter 1000; 10 concurrent creates distinct, counter +10, no 1062; hand-set `TEST-HAND-1` kept, counter unchanged; Compass Canada → row 14 SA. 2,000,000-counter uniqueness test passed.
72
+ 2026-10-18 local, real runs: seeds correct for Client_True / Compass / Elite (Compass SA continues SA139197; Canada SAC after SAC100923); single create AJ100000; 10 concurrent creates distinct + consecutive, no 1062; hand-set kept; Elite→True switch in one process used each client's own counter; Core client got CL146.
61
73
 
62
74
  ## Related
63
75
 
@@ -67,6 +79,7 @@ A model declares `public $number = [self::FIELD_AUTOINCREMENT];` — nothing els
67
79
  - [NetSuite SalesOrder outbound push](../../worker2/features/netsuite-salesorder-outbound-push.md)
68
80
 
69
81
  ## Change history
70
- - 2026-10-13 — Replaced per-tenant Parameters counter (PR 756, not merged) with platform-wide prefix + counter in `Core.Records`, scrambled format; removed FIELDOPT_AUTOINCREMENT_* options and Prudential usleep. TRUE-82715. (jcardinal)
71
- - 2026-10-02 — Per-tenant Parameters counter on `_beta` (superseded). (rgirish)
82
+ - 2026-10-18 — Final design: per-client `RecordNumbers` table (client DBs + Core), counting in order, old series continued under new prefixes, Compass Canada SAC; dropped the platform-wide scrambled `Core.Records` counter. TRUE-82715. (jcardinal)
83
+ - 2026-10-13 — Platform-wide scrambled counter in `Core.Records` (dropped before release). (jcardinal)
84
+ - 2026-10-02 — Per-tenant Parameters counter on `_beta` (superseded, PR 756 not merged). (rgirish)
72
85
  - 2026-09-02 — `TA` added to shared TransferOrder (now TR). (bala)
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-17
9
+ updated: 2026-10-18
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - _underscore/Component/RecordDelete/RecordDelete.php
@@ -53,6 +53,8 @@ Tickets, Cases, ServiceRequests, Customers, Contacts. The class DocBlock is the
53
53
  ContactNotes, ContactAttempts, ContactMessagingOptIns, Campaigns_Contacts, Campaigns_MeetWithContacts,
54
54
  Contacts_Tags, Contacts_Vendors, Contacts_Locations, Locations_Contacts, ContactAddresses / EmailAddresses /
55
55
  PhoneNumbers. `preDelete` clears the contact's `primary*` pointers first (they and the children point at each other).
56
+ - **Role rule (Records 274):** blocked by Users_Roles / Apis_Roles; ACL rows handled after `purge()` — full rule in
57
+ [ACL Capabilities § Role delete](acl-capabilities.md).
56
58
 
57
59
  ## Gotchas
58
60
 
@@ -71,5 +73,6 @@ Tickets, Cases, ServiceRequests, Customers, Contacts. The class DocBlock is the
71
73
  losing the link would lose meaning (orders, required FKs like `Contacts.customerId`).
72
74
 
73
75
  ## Change history
76
+ - 2026-10-18 — Roles opted in (TRUE-82880). (jcardinal)
74
77
  - 2026-10-17 — Contacts opted in (block / detach / cascade map, schema-drift probes); Contacts_Files added to FILE_REFERENCE_TABLES (TRUE-82871). (jcardinal)
75
78
  - 2026-10-17 — Created; Customers opted in, Customers_Files added to FILE_REFERENCE_TABLES (TRUE-82870). (jcardinal)
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-15
9
+ updated: 2026-10-18
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - _underscore/Model/Core/Record.php
@@ -22,15 +22,22 @@ Per-route record meta (names, label/sublabel field, per-field display labels); o
22
22
  ## Summary
23
23
 
24
24
  `GET /v2/records/meta?route=<route>` → `_Model_Core_Record::meta()`. Returns
25
- `{route, name, pluralName, labelField, sublabelField, identifierField, fieldLabels}`.
25
+ `{route, name, pluralName, icon, iconWeight, labelField, sublabelField, identifierField, fieldLabels}`.
26
+ Batch: `?routes=a,b,c` (max `META_BATCH_MAX_ROUTES` = 400; callers chunk) →
27
+ `{records: {<route>: {route, name, pluralName, icon, iconWeight}}}` — light shape, no field labels.
28
+ Record-type icons come only from here (`Core.Records.iconToken` / `iconWeightToken`); apps never
29
+ hardcode them (rule: dbchanges2 architecture → *`Core.Records.name` + icons*).
26
30
  `fieldLabels` = `{<field>: "<label>"}` — the data-driven visible label of each field, in the session
27
31
  language. Platform rule: **a field's visible label comes from `Core.RecordFields.labelMessageId` via
28
32
  `fieldLabels`, never hardcoded** (platform apps' `PLATFORM.md` rule 15).
29
33
 
30
34
  ## How it works
31
35
 
32
- - **Names:** `resolveNames()` → `name` / `pluralName` from Messages via
33
- `_Model_Core_Surface::resolveMessageValues()`.
36
+ - **Names:** `name` / `pluralName` from Messages via `_Model_Core_Surface::resolveMessageValues()`;
37
+ fallback order: Message → frozen `DefaultRecordSettings` → `Core.Records.name`.
38
+ - **Batch (`metaForRoutes`):** one Records query, one Messages lookup, one `DefaultRecordSettings` query for all routes.
39
+ - **Response path:** record-script nesting adds the payload key — batch is read at
40
+ `data.records.meta.records.<route>` (see [record scripts](../../api2/features/record-scripts.md)).
34
41
  - **`fieldLabels`:** `resolveFieldLabels()`:
35
42
  1. Caller must have record READ (`aclRecordFlags`), else `{}`.
36
43
  2. Only fields the caller may READ (`$api->getAclFieldPermissions`, same resolver as
@@ -47,11 +54,12 @@ language. Platform rule: **a field's visible label comes from `Core.RecordFields
47
54
  ## Gotchas
48
55
 
49
56
  - **⚠ Open (security): the base meta has NO record READ check.** `route`, `name`, `pluralName`,
50
- `labelField`, `sublabelField` come back for any route to any caller (pre-existing); only
51
- `fieldLabels` is gated. A caller can probe which routes exist (unknown route → 400
57
+ `labelField`, `sublabelField`, `icon` come back for any route to any caller (pre-existing; the
58
+ batch form too); only `fieldLabels` is gated. A caller can probe which routes exist (unknown route → 400
52
59
  `No record found for route`). Not fixed yet — Desk question `lx`.
53
60
  - `fieldLabels` is **per caller** (field ACL + language) — never cache it across users or tenants.
54
61
  The rest of the meta is the same for every tenant.
55
62
 
56
63
  ## Change history
64
+ - 2026-10-18 — Added `icon` / `iconWeight`, name fallback to `Core.Records.name`, and batch `?routes=` (max 400) (TRUE-82880). (jcardinal)
57
65
  - 2026-10-15 — Added `fieldLabels` (gated by record + field READ) and blox `fieldLabelOf`; noted the ungated base meta (TRUE-82836). (jcardinal)
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-02
9
+ updated: 2026-10-18
10
10
  owners: [jcardinal, bala, ajean, rgirish]
11
11
  files:
12
12
  - api2/Controller/Index.php
@@ -34,7 +34,7 @@ api2's front controller ("**Prong A**") can **detect a retriable concurrency fai
34
34
 
35
35
  The replay is **route-scoped by an allowlist** (`DEADLOCK_RETRIABLE_ROUTES` — currently `['/v2/advance-shipping-notices', '/v2/service-requests']`) — because whole-request in-process replay is **only safe for routes with no non-rollbackable external side effect**. The retry gate is now **`wasDeadlock() || wasRetriableUniqueRace()`**.
36
36
 
37
- > **Status (2026-10-02).** The SR number race is now fixed at the source — a per-tenant `Parameters` counter in `_Model` (TRUE-82209, on `_underscore` `_beta`/`_sandbox-dev`), see [FIELD_AUTOINCREMENT record numbering](../../_underscore/features/autoincrement-record-numbering.md). The `/v2/service-requests` replay below (api2 `_sandbox-dev` only; `_beta`/`_production` list ASN only) is no longer the fix for numbers. **Extending replay to every numbered route was rejected**: Adyen / Elite / Growrk `postPost` hooks have external side effects. Prod still has neither fix.
37
+ > **Status (2026-10-18).** The SR number race is fixed at the source — a per-client `RecordNumbers` counter in `_Model` (TRUE-82715; replaces the unmerged `Parameters` counter of TRUE-82209 / PR 756), see [FIELD_AUTOINCREMENT record numbering](../../_underscore/features/autoincrement-record-numbering.md). The `/v2/service-requests` replay below (api2 `_sandbox-dev` only; `_beta`/`_production` list ASN only) is no longer the fix for numbers. **Extending replay to every numbered route was rejected**: Adyen / Elite / Growrk `postPost` hooks have external side effects. Prod still has neither fix.
38
38
 
39
39
  **Constants (verified 2026-10-02):** `MAX_DEADLOCK_ATTEMPTS = 5`, backoff 20ms-120ms widening per attempt. `DEADLOCK_RETRIABLE_ROUTES` = `['/v2/advance-shipping-notices', '/v2/service-requests']` on api2 `_sandbox-dev`; ASN only on `_beta` and `_production`.
40
40
 
@@ -82,7 +82,7 @@ Two consequences of the typed exception worth knowing:
82
82
  **Symptom:** `/v2/service-requests` returned `400 EV-10` *"Duplicate entry 'SR131023' for key 'ServiceRequests.number'"* — 21 consecutive failures, all ~1.8s with no backoff visible. ServiceNow was **not** sending duplicates. The retry existed and was correctly wired; it just never fired.
83
83
 
84
84
  **The root-cause chain, in order:**
85
- 1. **`ServiceRequests.number` is not a MySQL auto-increment.** The old `_Model` `FIELD_AUTOINCREMENT` did `SELECT MAX(number)+1` then an `INSERT`, **with no lock between them** (since replaced by a counter on `_beta`/`_sandbox-dev`) (see [FIELD_AUTOINCREMENT record numbering](../../_underscore/features/autoincrement-record-numbering.md)). Two concurrent POSTs read the same MAX, pick the same number, and the loser gets MySQL 1062.
85
+ 1. **`ServiceRequests.number` is not a MySQL auto-increment.** The old `_Model` `FIELD_AUTOINCREMENT` did `SELECT MAX(number)+1` then an `INSERT`, **with no lock between them** (since replaced by the `RecordNumbers` counter, TRUE-82715) (see [FIELD_AUTOINCREMENT record numbering](../../_underscore/features/autoincrement-record-numbering.md)). Two concurrent POSTs read the same MAX, pick the same number, and the loser gets MySQL 1062.
86
86
  2. **The retry was already built for exactly this.** `_underscore/Model.php` set `_Database::$retriableUniqueRace` on that 1062 (removed by TRUE-82209 — no branch's `Model.php` sets it now), and `api2/Controller/Index.php` replays the whole request when `_Database::wasRetriableUniqueRace()` is true. That helper requires **both** the flag **and** `$lastErrorNumber === 1062`.
87
87
  3. **The logger destroyed the second half of that condition.** `_Component_Api_V2::saveApiLogUnderItsOwnTransactionId()` runs **between the failed INSERT and the retry check**, inside `execute()`. Its resend loop **deliberately causes its own 1062s** — it renames `transactionId` on collision and retries (see [V2 request logging](./request-logging.md)). Those bookkeeping 1062s **overwrote the caller's `$lastErrorNumber`**, so `wasRetriableUniqueRace()` returned false and the replay never ran.
88
88
 
@@ -6,7 +6,7 @@ project: Database Changes
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-10-17
9
+ updated: 2026-10-18
10
10
  owners: [jcardinal, mhammontree, bala, ajean]
11
11
  files:
12
12
  - Core/
@@ -141,6 +141,19 @@ These **two tables, and only these two**, have **team-maintained primary keys**.
141
141
 
142
142
  This is what makes the *Database isolation* rule practical — it is the reason a `Client_<Tenant>` migration never needs to read `Core` to find a `recordFieldId`.
143
143
 
144
+ ### `Core.Records.name` + icons (team rule 2026-10-18, TRUE-82880)
145
+
146
+ - `name` is REQUIRED (NOT NULL), written for a person: Title Case, PLURAL, built from the route. `sales-orders` → "Sales Orders". Acronyms stay upper case: "ACL Record Permissions".
147
+ - Bridge record = BOTH sides are real tables/records. Both sides plural: "Locations <-> Location Attributes", "Users <-> Roles", "Users <-> Files".
148
+ - Child table whose second part is not its own table is NOT a bridge: one plural name ("Location Hours", not "Location <-> Hours"); table `LocationHours`, not `Locations_Hours`.
149
+ - Typed variants: type first: `support-methods-chat` → "Chat Support Methods".
150
+ - Product / feature names keep their form ("Meet With Contacts"). Uncountable words stay singular ("User Memory", "Conversation History", "Item Media"). Acronyms upper case ("AI Models").
151
+ - Routes match the table words: `service-request-note-types`, not `service-request-notes-types`.
152
+ - Change history: 2026-10-18 TRUE-82880 refined bridge/child/typed/uncountable rules.
153
+ - Every new `Core.Records` insert sets `name` this way.
154
+ - Two layers: `Core.Records.name` = base name, the fallback everywhere; `nameMessageId` / `pluralNameMessageId` Messages = translatable UI text, wins when set.
155
+ - Record-type icons live in `Core.Records.iconToken` (+ `iconWeightToken`): one icon per record platform-wide; the nav icon is the source when choosing. Apps read it from the records meta, never hardcode record icons. Stored as camelCase Font Awesome names without the `fa-` prefix (blox `toFaClass` normalizes).
156
+
144
157
  ### Inserting into them — ASK FIRST, never rely on AUTO_INCREMENT
145
158
 
146
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:
@@ -164,6 +177,7 @@ The rule above keeps *existing* ids aligned, but each environment's `Core` has d
164
177
 
165
178
  - **Never subselect for a Core id from a `Client_*` file** — `Core` and `Client_*` are on separate clusters, so it cannot resolve there; write the literal.
166
179
  - When a change inserts a **new** `Core.RecordFields` row *and* client-side rows that reference it, expect the reference to need a **per-environment value**, and say so in a header comment. A single file that "works everywhere" is only safe when every id it touches is long-standing Core data (as in `Client_Elite/2026-08-17 - FulfillmentTableViews.sql`, which needed no split).
180
+ - **A dev's local `Core` can hold stray `RecordFields` rows** not in prod/dev-sandbox/repo (TRUE-82715: local had `clients.number` as 2598) → a guarded insert of the central id hits unique `recordId_field`. Correct: the file fails loud. Fix by deleting the stray row locally — never change the central id.
167
181
  - This qualifies the claim in `2.0/apps/_underscore/features/acl-permission-chain.md` that these ids are "identical in every environment": true for established rows, **not** for a row you are creating now.
168
182
 
169
183
  ### Referencing them — hardcode the `id`, including foreign keys
@@ -400,6 +414,7 @@ The extra `SELECT … FROM ( … ) existing` layer both dodges error 1093 and pi
400
414
 
401
415
  ## Change history
402
416
 
417
+ - 2026-10-18 — Core.Records.name required + human naming; record icons in Core.Records (TRUE-82880). (jcardinal)
403
418
  - 2026-10-17 — /finish reports central ids used + next free (TRUE-82889). (jcardinal)
404
419
  - 2026-10-17 — Second same-folder file also allowed for interceptor rows that must run after the code deploy (TRUE-82889). (jcardinal)
405
420
  - 2026-10-13 — Added rule 15: no MySQL `CHECK` constraints (TRUE-82715). (jcardinal)
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-05
9
+ updated: 2026-10-18
10
10
  owners: ["bala", "jcardinal"]
11
11
  files:
12
12
  - worker2/Worker/Netsuite/SalesOrder.php
@@ -136,16 +136,16 @@ At depth 4 the address comes back **missing with no error** — the max-depth om
136
136
 
137
137
  Same mechanism as the sales-order push (`created` / `adopted` / `recovered` / `dryRun`), different prefix. **Keyed on the uuid, deliberately:**
138
138
 
139
- 1. NetSuite account **1095849 is shared by every client** and rejects a duplicate `externalId` **account-wide**, not per customer. `number` is unique only inside one client database — and with `TA` numbering on the *shared* model, **every client starts at `TA100000`**.
139
+ 1. NetSuite account **1095849 is shared by every client** and rejects a duplicate `externalId` **account-wide**, not per customer. `number` is unique only inside one client database — and record numbers are counted per client (`TR`, was `TA`), so **two clients can hold the same number**.
140
140
  2. **`number` changes.** The write-back replaces `TA100000` with NetSuite's number ~12s later, so an external id built from `number` would stop matching, the duplicate check would miss, and a second NetSuite order would be created.
141
141
 
142
142
  The uuid is the only value on the row unique everywhere and never moving. `externalId` is a **NetSuite-only** field, written nowhere in the Toga DB, and is **not** the internal id — NetSuite assigns `internalId` itself and the bare number lands in `c_netsuiteInternalSalesOrderId`.
143
143
 
144
144
  > **⚠ The format is frozen once orders carry it.** Changing the `toga-to-` prefix makes the adopt/recover path miss existing transfers and create duplicates.
145
145
 
146
- ### `TransferOrders.number` is a PLACEHOLDER (`TA100000`), replaced by NetSuite's
146
+ ### `TransferOrders.number` is a PLACEHOLDER (`TR…`, was `TA…`), replaced by NetSuite's
147
147
 
148
- `number` is now `FIELD_AUTOINCREMENT` with prefix `TA` from 100000, mirroring `_Model_Client_SalesOrder`'s `SA` — on the **shared** client model, at the developer's instruction, so every client's transfer orders get it. Write-back sets `number` to NetSuite's `tranId` immediately after the create, exactly as `SA` numbers behave. Mechanics + the reverted "keep TA forever" attempt: [autoincrement record numbering](../../_underscore/features/autoincrement-record-numbering.md). Short version: **the generator only fires when `number` arrives NULL**, so the NetSuite importer (which sends `number = $nsOrder->tranId`) is unaffected — and the **Create Transfer Order modal must stop sending `number`** or the TA number is never generated.
148
+ `number` is `FIELD_AUTOINCREMENT` on the **shared** client model, so every client's transfer orders get it. Prefix `TR` since TRUE-82715 (2026-10-18; was `TA`), continuing each client's old TA series, mirroring `SA` on sales orders. Write-back sets `number` to NetSuite's `tranId` immediately after the create, exactly as `SA` numbers behave. Mechanics + the reverted "keep TA forever" attempt: [autoincrement record numbering](../../_underscore/features/autoincrement-record-numbering.md). Short version: **the generator only fires when `number` arrives NULL**, so the NetSuite importer (which sends `number = $nsOrder->tranId`) is unaffected — and the **Create Transfer Order modal must stop sending `number`** or the TA number is never generated.
149
149
 
150
150
  ### The trigger — `postPost` on the SHARED `_Model_Client_TransferOrder`
151
151
 
@@ -266,7 +266,7 @@ When the payload omits the ship-to address the create **succeeds**, so it looks
266
266
 
267
267
  - **The interceptor row is authored `isActive = 0` per the standard but shipped `isActive = 1`.** `_underscore` **must** be deployed before that migration runs, or every transfer-order POST 500s with *"Call to undefined method"* — the dispatch is unguarded (see [API payload interceptors](../../api2/features/api-payload-interceptors.md), and the Elite production incident for what that looks like live).
268
268
  - **The worker's transfer-order read must stay at depth 6** — shallower silently loses the PO number (aborts the push) and the ship-to address. Do not "optimise" the depth.
269
- - **TA placeholder numbers get reused** after write-back replaces them (TA100001 was TO 1970, later TO 1975). Harmless — only unsent orders keep a TA number; never key on it.
269
+ - **Placeholder numbers were reused under the old `MAX()+1` generator** after write-back replaced them (TA100001 was TO 1970, later TO 1975). The `RecordNumbers` counter (TRUE-82715) no longer reuses them. Only unsent orders keep a placeholder; never key on it.
270
270
  - **This action does not retry itself**, exactly like the sales-order push: a throw records `isSuccess = 0` and stops. An event-triggered push fires once.
271
271
  - **A NetSuite HTTP 400 naming a tax code is a CUSTOMER data problem, not a payload problem.** Check the customer's `taxitem` first; do not pin the tax fields in the payload (ignored) and do not drop the address to make the error go away (silently ships to the wrong site).
272
272
  - **`git diff --stat` after any programmatic full-file write to `_underscore`.** A full-file write with an editor default flipped CRLF → LF on `_underscore/Model/Nychh/TransferOrder.php` — content identical, git reported it modified with no content hunks. Restored with `git checkout --`. Standing rule: [2.0 backend PHP standards](../../standards/backend-php.md).
@@ -5,7 +5,7 @@ project: _Underscore
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-10-06
8
+ updated: 2026-10-18
9
9
  owners: [jcardinal, ajean, apeterson, mhammontree]
10
10
  files: []
11
11
  related:
@@ -171,6 +171,10 @@ literal rather than a lookup — including in SQL embedded in PHP in `worker2` /
171
171
  `_underscore`. Always add a comment naming the record/field the number refers to; a bare `4188` is
172
172
  unverifiable later.
173
173
 
174
+ **Naming + icons.** `Core.Records.name` is required: Title Case, plural, from the route
175
+ ("Sales Orders"; bridge records, both sides plural: "Users <-> Roles"; child tables are not bridges: "Location Hours"). Record icons live in `Core.Records.iconToken`
176
+ (+ `iconWeightToken`), never hardcoded in apps. Rule + examples: dbchanges2 architecture, same section.
177
+
174
178
  This is also what makes the `dbchanges2` isolation rule above workable: a `Client_<Tenant>` change
175
179
  never has to read `Core` to resolve a `recordFieldId`. Full detail and examples:
176
180
  `2.0/apps/dbchanges2/architecture.md` → *`Core.Records` / `Core.RecordFields`*.
@@ -21,7 +21,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
21
21
 
22
22
  ## 2.0 framework
23
23
 
24
- - **_underscore** (_Underscore) _(framework core)_ — 104 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
24
+ - **_underscore** (_Underscore) _(framework core)_ — 105 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
25
25
  - **worker2** (Worker) — 90 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
26
26
  - **api2** (API) — 27 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
27
27
  - **dbchanges2** (Database Changes) _(framework core)_ — 22 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -19,7 +19,7 @@ project: _Underscore
19
19
  client: compass-canada
20
20
  type: profile
21
21
  status: active
22
- updated: 2026-10-16
22
+ updated: 2026-10-18
23
23
  owners: [jcardinal, bala, tcox, apeterson, ajean, mhammontree, rgirish]
24
24
  files: []
25
25
  related:
@@ -166,6 +166,7 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
166
166
  `languageSwitcher.isEnabled = true` (`fieldsConfig/clientFields/COMPASSCANADA.json`).
167
167
 
168
168
  ## Notes
169
+ - **Sales order numbers use prefix `SAC`, not `SA` (TRUE-82715, 2026-10-18).** Override row in `Client_CompassCanada.RecordNumbers` (`dbchanges2/Client_CompassCanada/2026-10-18a - RecordNumbers SAC prefix.sql`, runs after the `Client/` file); continues after SAC100923. [Record numbering](../../2.0/apps/_underscore/features/autoincrement-record-numbering.md).
169
170
  - **⚠ Canada managers got NO approval reminders 2026-08-04 → TRUE-82868 fix (2026-10-16, not yet deployed)** — the reminder cron crashed every run on an ambiguous `firstName`. Same 30-day reminder/cancel split as USA: [Approval reminder & auto-cancel crons](../../1.0/apps/worker/features/compass-manager-approval-reminder-emails.md).
170
171
  - **VIP support techs come from the shared Compass VIP file.** One quarterly file covers both portals; Canada VIPs get the Canada tech in `Client_CompassCanada.Users.c_supportedByUserId`. See [VIP Support Import](../../2.0/apps/worker2/features/compass-vip-support-importer.md).
171
172
  - **Item modal "Restrict to Persona" needed a Surface opt-in that was never written (2026-09-04).**
@@ -23,7 +23,7 @@ project: _Underscore
23
23
  client: compass-usa
24
24
  type: profile
25
25
  status: active
26
- updated: 2026-10-16
26
+ updated: 2026-10-18
27
27
  owners: [jcardinal, bala, tcox, apeterson, dfranks, akhokhani, ajean, rgirish]
28
28
  files: []
29
29
  related:
@@ -233,6 +233,7 @@ separate, related client (see its own profile).
233
233
  the ⚠ rule that **deactivation is raw bulk SQL, so no model hook ever fires on it**.
234
234
 
235
235
  ## Notes
236
+ - **Record numbers are Compass's own series, counted in order (TRUE-82715, 2026-10-18).** Compass checks its own order numbers for gaps; SA continues after SA139197. A gap = a failed create (check `Logs.Api` / `Logs.Issue`). [Record numbering](../../2.0/apps/_underscore/features/autoincrement-record-numbering.md).
236
237
  - **⚠ Approval auto-cancel never fired for 926 legacy-migrated orders (TRUE-82868, 2026-10-16, not yet deployed).** Their admin approval has `dtDecision` NULL; the crons now age from `COALESCE(dtDecision, SalesOrders.dtCreated)`, reminders stop at 30 days, cancel starts after. [Approval reminder & auto-cancel crons](../../1.0/apps/worker/features/compass-manager-approval-reminder-emails.md).
237
238
  - **⚠ SALES_ORDERS froze 2026-10-02 → 10-05 (Issue 940): closed-order delete matched the Office Depot twin SO** (dual-SO trap). Fixed in library with the per-client scope; open: higher-tier mirror fulfillments 91650/91651. Details: [closed-order rule](../../1.0/apps/library/features/netsuite-closed-order-rule.md).
238
239
  - **⚠ SALES_ORDERS froze 2026-09-29 on ACL gaps the closed-order delete hit** (transfer-orders 312/313, entitlement-sales-orders 257, sales-order-payments 254). Fixed by `dbchanges2/Client_Compass/2026-09-29a - TransferOrderAclExpressions.sql`, then mode reset to `864000-IDLE`. Details: [closed-order rule](../../1.0/apps/library/features/netsuite-closed-order-rule.md).
@@ -36,7 +36,7 @@ OneUptime: Incoming Request monitor, DEGRADED status id `8a160361-0b6a-4cbb-ad10
36
36
 
37
37
  ## Gotchas
38
38
  - **When it fires:** the order was never queued, or its push failed. Check `Core.WorkerJobs` for the action + uuid; no row = never queued (look in `Logs.Issue` for the `captureException` from `_Model_Client_TransferOrder::postPost`). Re-queue recipe: [SQS-cap gotcha](../../../2.0/apps/worker2/features/netsuite-transferorder-outbound-push.md#-the-job-carries-only-the-uuid--never-the-order-sqs-cap-incident-2026-10-05).
39
- - A listed number may be a `TA` placeholder; TA numbers are reused after write-back, so identify the order by uuid/id, not number.
39
+ - A listed number may be a `TR` (old: `TA`) placeholder that write-back will replace, so identify the order by uuid/id, not number.
40
40
  - No exclusion for cancelled orders — a cancelled TO with no NetSuite id keeps it DEGRADED.
41
41
 
42
42
  ## Change history
@@ -95,7 +95,7 @@ Recipient is `DevTeam@togatech.com` while testing. `2026-09-15b` swaps it to
95
95
  ## ⚠ Submitted must fire on `postPut` — the POST number is a placeholder
96
96
 
97
97
  TOGa Supply saves a transfer order with the shared-model `FIELD_AUTOINCREMENT` placeholder number
98
- prefixed **`TA`** (e.g. `TA100000`). The real NetSuite number arrives about **12 seconds later** on
98
+ prefixed **`TR`** (was `TA` before TRUE-82715, e.g. `TA100000`). The real NetSuite number arrives about **12 seconds later** on
99
99
  the write-back PUT from `worker2/Worker/Netsuite/SalesOrder.php`, which PUTs
100
100
  `c_netsuiteInternalSalesOrderId` and `number`. Announcing on the POST would quote `TA100000` to the
101
101
  customer.
@@ -17,7 +17,7 @@ project: _Underscore
17
17
  client: prudential
18
18
  type: profile
19
19
  status: active
20
- updated: 2026-10-16
20
+ updated: 2026-10-18
21
21
  owners: ["jcardinal", "rgirish", "bala", "mhammontree"]
22
22
  files: []
23
23
  related:
@@ -122,7 +122,7 @@ The order-created cron branches on this id; the shipped/delivered crons branch o
122
122
  features/service-request-auth-rejection-alert.md.
123
123
 
124
124
  - **SR number race (TRUE-82209: ServiceNow bursts → 1062 `Duplicate entry 'SR…'` → 400 `EV-10`, SR lost).** Fixed by
125
- platform-wide record numbers (TRUE-82715); the `usleep` stopgap in `_Model_Prudential_ServiceRequest::prePost()`
125
+ per-client record numbers from `RecordNumbers` (TRUE-82715; SR series continues); the `usleep` stopgap in `_Model_Prudential_ServiceRequest::prePost()`
126
126
  is removed. Load-test with a distinct contact + serial per call and `customer.uuid` set. See
127
127
  [Record numbering](../../2.0/apps/_underscore/features/autoincrement-record-numbering.md).
128
128
 
@@ -17,11 +17,12 @@ apps:
17
17
  - togadesk
18
18
  - toga25-desk
19
19
  - toga25-blox
20
+ - toga25-iq
20
21
  project: _Underscore
21
22
  client: true
22
23
  type: profile
23
24
  status: active
24
- updated: 2026-10-16
25
+ updated: 2026-10-18
25
26
  owners: [jcardinal, kyalamarthi, tcox, rgirish, mhammontree, akhokhani]
26
27
  files: []
27
28
  related:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.1074",
3
+ "version": "1.0.1076",
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",