toga-ai 1.0.1063 → 1.0.1065

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.
@@ -23,6 +23,7 @@
23
23
  | [_Config group access — the two-argument "optional" form does NOT rescue a missing GROUP](features/config-group-access.md) | `_Config::<group>('prop', false)` does NOT rescue a MISSING GROUP (only a missing prop) — guard with `_Config::isGroupSet()`; open before reading per-environmen |
24
24
  | [Contacts require a customerId — the Internal customer & every contact-creation path](features/contact-customer-required-internal-customer.md) | Every `Client_<Tenant>.Contacts` row must have a `customerId`; with no real customer, use the shared Internal customer (fixed uuid in every tenant). |
25
25
  | [Core.Domains — the app host registry (and why you cannot derive a host)](features/core-domains-app-host-registry.md) | `Core.Domains` is the authoritative host↔client↔app↔env registry; a host is NOT derivable from env slug or api host — read the table. |
26
+ | [Customer related records — primary location, Locations_Customers, files, notes](features/customer-locations-files-notes.md) | Shared Client-DB data model around a Customer (locations, files, notes, contact count); open before linking anything to a customer or listing a customer's locat |
26
27
  | [Re-pointing a DB alias mid-request (_Database::register park/restore)](features/database-alias-repointing.md) | `_Database` keys **all live per-database runtime state by the connection ALIAS** (`Client` / `_underscore::DB_CLIENT`, `ClientLogs`, `Archive`), **not** by the |
27
28
  | [Effective persona resolution (_Model_Client_User::_personaIds) — the three sources and the sector hop](features/effective-persona-resolution.md) | A user's EFFECTIVE persona set = UNION of three sources (`Users_Personas` + own-location + 5-hop sector `Locations_Personas`), owned by `_personaIds()`; open be |
28
29
  | [2.0 Email Send Pipeline (queue + Send worker)](features/email-send-pipeline.md) | In 2.0, `_Email::send()` **does not transmit** — it queues the message. |
@@ -49,6 +50,7 @@
49
50
  | [Persona nav-visibility gate + user nav pins (keyed by Core.SurfaceElements uuid)](features/persona-nav-visibility-gate.md) | Sidebar-nav persona gate in `_Model_Core_Surface` (`Client.PersonaNavVisibility`) and user nav pins (`Client.UserNavPreferences`), both keyed by Core `SurfaceEl |
50
51
  | [Record activity timeline (GET /v2/record-logs/timeline, _Component_RecordActivity)](features/record-activity-timeline.md) | Generic per-record activity feed built from Logs_<Tenant>; open when adding a new timeline root, hiding noise fields, or debugging a missing activity row. |
51
52
  | [Record Change Audit Log (Logs_<Client>.Record / RecordField) — reading a field's history](features/record-change-audit-log.md) | Field-level change audit trail per tenant; open to answer "who changed this field, to what, when" or to tell a direct SQL edit from an in-app change. |
53
+ | [Record hard delete (_Component_RecordDelete + PRE DELETE interceptor)](features/record-hard-delete.md) | Map-driven hard delete of everything hanging off one record (block / detach / delete children / orphan Files); open before making a record deletable or adding a |
52
54
  | [Records meta endpoint (GET /v2/records/meta) — record names, label fields, translated field labels](features/record-meta-endpoint.md) | Per-route record meta (names, label/sublabel field, per-field display labels); open when a screen needs a record's or a field's visible name, or when adding a f |
53
55
  | [Recursive Item Fulfillments (upstream mirroring)](features/recursive-item-fulfillments.md) | Engine that mirrors a downstream ItemFulfillment up the SO→PO→SO chain onto every customer-facing SO; open when touching IF creation, bundle scaling, or upstrea |
54
56
  | [Sales-order denial reason (`ApprovalDecisions.note`) — capture, the denial-details card, and the read-only workflow mode](features/sales-order-denial-reason.md) | Map of the SO approval denial reason (`ApprovalDecisions.note`) — where it is written, read, and displayed; open when building any denial-reason display, banner |
@@ -0,0 +1,71 @@
1
+ ---
2
+ title: Customer related records — primary location, Locations_Customers, files, notes
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-10-17
10
+ owners: [jcardinal]
11
+ files:
12
+ - _underscore/Model/Client/Customer.php
13
+ - _underscore/Model/Client/Locations/Customer.php
14
+ - _underscore/Model/Client/Customers/File.php
15
+ - _underscore/Model/Client/CustomerNote.php
16
+ - dbchanges2/Core/2026-10-17b - CustomerLocationsFilesNotesActivity.sql
17
+ - dbchanges2/Client/2026-10-17b - CustomerLocationsFilesNotes.sql
18
+ related:
19
+ - ./record-hard-delete.md
20
+ - ./record-activity-timeline.md
21
+ - ./note-record-authorship.md
22
+ - ./tableview-joins.md
23
+ - ./model-save-parent-cascade-stored-field-deadlock.md
24
+ - ./contact-customer-required-internal-customer.md
25
+ - ./acl-permission-chain.md
26
+ ---
27
+ Shared Client-DB data model around a Customer (locations, files, notes, contact count); open before linking anything to a customer or listing a customer's locations.
28
+
29
+ ## Summary
30
+
31
+ Added 2026-10-17 (TRUE-82870, built for TOGa Desk, usable by any app). All tables live in every `Client_*` DB.
32
+
33
+ | Piece | Shape | Core ids |
34
+ |---|---|---|
35
+ | `Customers.primaryLocationId` | FK → Locations, standard field | RecordFields 2799 |
36
+ | `Customers._contactCount` | non-stored `FIELD_SQL` COUNT of Contacts by `customerId` | RecordFields 2800 |
37
+ | `Locations_Customers` | many-to-many bridge, `UNIQUE (locationId, customerId)` | Records 382 `location-customers` |
38
+ | `Customers_Files` | copy of `Cases_Files`, `UNIQUE (customerId, fileId)` | Records 383 `customer-files` |
39
+ | `CustomerNotes` | copy of `CaseNotes` minus stage: write-once, internal | Records 384 `customer-notes` |
40
+
41
+ RecordFields 2801-2814 cover the three new records.
42
+
43
+ ## How it works
44
+
45
+ - **Location of a customer:** use `primaryLocationId` (a real join). `_location` (city, state from the bill-to
46
+ Address) is kept for its existing readers — do not build new features on it.
47
+ - **Bridge, not `Locations.customerId`:** a location may belong to many customers (developer decision).
48
+ - **Inherent children:** `customers → location-customers` and `locations → location-customers` (isParentTouched 0),
49
+ `customers → customer-files` and `customers → customer-notes` (isParentTouched 1). Child keys:
50
+ `locationCustomers[]`, `customerFiles[]`, `customerNotes[]`.
51
+ - **CustomerNotes author:** `authorUserId` stamped in `save()` from `_Model::getRequestUserId()`, never from the
52
+ payload (nested creates skip the child's PRE POST interceptor, so it cannot live there). No client user
53
+ (API key, worker) → 400. Updates refused.
54
+ - **Client ACL:** full 4-piece chain, granted to every role with a global read on customers (Base locally).
55
+ 382/383: CRUD. 384: create + read only, only `note` writable.
56
+ - **Delete:** see [record hard delete](./record-hard-delete.md). **Activity:** root `customers` →
57
+ surface `customer-activity` (see [record activity timeline](./record-activity-timeline.md)).
58
+ - **Listing a customer's locations:** separate parent-scoped TableView `desk-customer-locations` (Locations base,
59
+ OUTER joins → Locations_Customers → Customers, filtered by `Customers.uuid`). See gotchas.
60
+
61
+ ## Gotchas
62
+
63
+ - **Never OUTER-join a many-to-many bridge into a shared TableView.** blox sends every join of a view on every
64
+ request (`buildJoinOptions`), so the general listing repeats each row once per bridge match. Make a
65
+ separate parent-scoped view instead (rule lives in [tableview-joins](./tableview-joins.md)).
66
+ - **`_contactCount` must stay non-stored** — a stored self-aggregate deadlocks the save cascade
67
+ ([details](./model-save-parent-cascade-stored-field-deadlock.md)).
68
+ - **Assets link to a customer through Contacts** (`Units.contactId → Contacts.customerId`), not directly.
69
+
70
+ ## Change history
71
+ - 2026-10-17 — Created: primaryLocationId, _contactCount, Locations_Customers, Customers_Files, CustomerNotes (TRUE-82870). (jcardinal)
@@ -63,10 +63,11 @@ Before building anything that shows or filters by note author:
63
63
 
64
64
  - **`contactId` on a `*Notes` model is an unwritten FK — treat it as always NULL.** Seven models, zero writers.
65
65
  - **A notes list UI has no author to show.** Design for it, or the field silently renders blank for every row.
66
- - **Exception — `CaseNotes` (2026-10-15) has a real author:** `authorUserId` stamped in `_Model_Client_CaseNote::save()` from `_Model::getRequestUserId()`. Copy that pattern for the real fix. See [Case status + stage gate](./case-status-and-stage-gate.md).
66
+ - **Exception — `CaseNotes` (2026-10-15) and `CustomerNotes` (2026-10-17) have a real author:** `authorUserId` stamped in `::save()` from `_Model::getRequestUserId()`. 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).
67
67
  - **`dtStamp` is `FIELD_DATETIME_CREATED`** — stamped by the ORM on insert, the only provenance a note actually carries.
68
68
 
69
69
  ## Change history
70
+ - 2026-10-17 — CustomerNotes added as a second server-stamped-author note model (TRUE-82870). (jcardinal)
70
71
  - 2026-10-15 — Noted CaseNotes as the first note model with a server-stamped author. (jcardinal)
71
72
 
72
73
  ## Related
@@ -32,7 +32,7 @@ client `AclRecordScripts`. Data source: [record change audit log](record-change-
32
32
 
33
33
  - **Roots** = `ROOT_ACTIVITY_SURFACE_SLUGS` (route → activity Surface slug): `tickets` →
34
34
  `ticket-activity`, `units` → `asset-activity`, `cases` → `case-activity`, `service-requests` →
35
- `service-request-activity`. Any other route → 400. **A new root = one map entry + its Surface
35
+ `service-request-activity`, `customers` → `customer-activity`. Any other route → 400. **A new root = one map entry + its Surface
36
36
  (`dbchanges2` `Core/`).** Engine (`Source` reads Logs, `Reader` checks as the reader through api2)
37
37
  is generic; no new client ACL needed for a new root.
38
38
  - **Children:** `Logs.RecordParent`, level by level, max `Source::MAX_DEPTH` (3) levels and
@@ -45,7 +45,7 @@ client `AclRecordScripts`. Data source: [record change audit log](record-change-
45
45
  `isVisible = 0`). An UPDATE with no visible field is dropped. Secrets → `{redacted:true}`.
46
46
  4. FK values → `{uuid, label[, slug]}`, `{missing:true}`, or `{value:null}` — never a raw id.
47
47
  - **CREATE rows of `CREATE_HIDDEN_ROUTES`** (`ticket-notes`, `ticket-note-files`, `unit-notes`,
48
- `case-notes`, `service-request-notes`) are dropped — the host screen merges the notes feed.
48
+ `case-notes`, `service-request-notes`, `customer-notes`) are dropped — the host screen merges the notes feed.
49
49
  Note edits/deletes still show.
50
50
  - **Paging:** cursor = `Logs.Record` id; `nextCursor` null at the end. Scan bounded
51
51
  (`MAX_SCAN_BATCHES` × `SCAN_BATCH_SIZE`); a page cut by the bound still returns a `nextCursor`.
@@ -69,5 +69,6 @@ client `AclRecordScripts`. Data source: [record change audit log](record-change-
69
69
  (schema + api2 change) — not built.
70
70
 
71
71
  ## Change history
72
+ - 2026-10-17 — Added root `customers` + `customer-notes` to `CREATE_HIDDEN_ROUTES` (TRUE-82870). (jcardinal)
72
73
  - 2026-10-17 — One-FK rule for parent links; no request id for grouping (TRUE-82889). (jcardinal)
73
74
  - 2026-10-15 — Added roots `units`, `cases`, `service-requests` + their note routes to `CREATE_HIDDEN_ROUTES` (TRUE-82836). (jcardinal)
@@ -0,0 +1,60 @@
1
+ ---
2
+ title: Record hard delete (_Component_RecordDelete + PRE DELETE interceptor)
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-10-17
10
+ owners: [jcardinal]
11
+ files:
12
+ - _underscore/Component/RecordDelete/RecordDelete.php
13
+ - _underscore/Model/Client/Customer.php
14
+ - _underscore/Model/Client/Ticket.php
15
+ - _underscore/Model/Client/Case.php
16
+ - _underscore/Model/Client/ServiceRequest.php
17
+ related:
18
+ - ../../api2/features/api-payload-interceptors.md
19
+ - ./customer-locations-files-notes.md
20
+ - ./record-activity-timeline.md
21
+ ---
22
+ Map-driven hard delete of everything hanging off one record (block / detach / delete children / orphan Files); open before making a record deletable or adding a table with a Files FK.
23
+
24
+ ## Summary
25
+
26
+ `DELETE /v2/<route>/{uuid}` on an opted-in record runs a PRE DELETE interceptor that calls
27
+ `_Component_RecordDelete::purge(['model' => static::class, 'id' => $id])`. `purge()` cleans the related
28
+ rows from a map the model declares in `relatedRows()`; api2 then deletes the record row itself. Used by
29
+ Tickets, Cases, ServiceRequests, Customers. The class DocBlock is the full contract — read it before editing.
30
+
31
+ ## How it works
32
+
33
+ - **Opt in (3 steps):** `public static function relatedRows(): array` on the model; `preDelete(&$api, &$payload)`
34
+ that calls `purge()` for a route-uuid delete; a `Core.ApiPayloadInterceptors` row (recordId, `PRE`, `DELETE`).
35
+ Cleanup must be PRE — a POST DELETE interceptor never runs (see api-payload-interceptors).
36
+ - **Map keys** (all optional): `blockers` (any match → 400 `_Exception_Validation`, nothing deleted), `detach`
37
+ (`UPDATE … SET col = NULL`, column must be nullable), `files` (collect file ids first), `polymorphic` (run
38
+ before children), `children` (ordered set-based DELETEs; grandchildren first). Entries: `{table, column, via?, where?}`.
39
+ - **Run order:** lock record row → blockers → collect file ids → detach → polymorphic → children → orphan Files.
40
+ - **Files:** a `Files` row is deleted only if no table in `FILE_REFERENCE_TABLES` still points at it. Storage
41
+ objects (S3) are never touched.
42
+ - **Transaction:** first statement is a no-op UPDATE on the record row — it opens api2's request transaction and
43
+ takes the lock; any failure rolls the whole request back. All statements on the write connection, one DB.
44
+ - **Customer rule (Records 113, 2026-10-17):** block on Contacts / SalesOrders / PurchaseOrders; detach Tickets /
45
+ ServiceRequests / Cases `customerId`; delete Customers_Catalogs, Customers_Subscriptions, Locations_Customers,
46
+ Customers_Files, CustomerNotes rows; orphan Files from Customers_Files.
47
+
48
+ ## Gotchas
49
+
50
+ - **New table with a `fileId` FK → add it to `FILE_REFERENCE_TABLES`.** Missing it makes the Files DELETE fail on
51
+ the FK and roll back every delete that touches a shared file (loud, not silent).
52
+ - **Client override of `relatedRows()` must merge `parent::relatedRows()` key by key;** a client `preDelete`
53
+ replaces the shared one and must call `parent::preDelete()`.
54
+ - **Use `$api->recordUuid ?? null`, never `isset($api->recordUuid)`** — `_Component` has `__get` but no `__isset`.
55
+ - **Load the record on the write connection** (read host off) — the read replica can lag this request's writes.
56
+ - **`detach` is for business records that outlive the parent** (e.g. Tickets.customerId); use `blockers` when
57
+ losing the link would lose meaning (orders, required FKs like `Contacts.customerId`).
58
+
59
+ ## Change history
60
+ - 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-09-16
9
+ updated: 2026-10-17
10
10
  owners: [bala, apeterson, jcardinal]
11
11
  files:
12
12
  - _underscore/Model/Client/TableView.php
@@ -31,6 +31,7 @@ related:
31
31
  - ./acl-permission-chain.md
32
32
  - ../../toga25-supply/features/transfer-orders-page.md
33
33
  - ../../toga25-supply/features/record-modals-and-nested-tables.md
34
+ - ./customer-locations-files-notes.md
34
35
  ---
35
36
 
36
37
  How `TableViewJoins` rows become a view's join SQL — aliasing, chained multi-hop joins, ACL gating, and the three ways a view breaks; open before adding/rebasing a join or debugging a view 500 / empty grid.
@@ -188,6 +189,15 @@ Cause: an **INNER** `TableViewJoins` on a foreign-key column that is **unpopulat
188
189
  - **Fix / avoid:** for an **optional / possibly-empty** FK, use **LEFT OUTER** (rows survive with nulls in the joined columns) or omit the column entirely. Only use INNER when the FK is guaranteed populated for the tenant.
189
190
  - **Check before you add:** verify FK population in the target tenant (`SELECT COUNT(*) ... WHERE <fk> IS NOT NULL`) before choosing INNER. NYCHH's `purchase-orders` ultimately **dropped** the Location (and Ship To) column rather than switch to OUTER.
190
191
 
192
+ ### A many-to-many join in a SHARED view duplicates rows in every listing that uses it
193
+
194
+ blox sends **every** join of a view on **every** request (`buildJoinOptions`), filtered or not. So an OUTER join to a
195
+ many-to-many bridge added "only for the parent-scoped tab" also runs in the plain listing, and each row repeats once
196
+ per bridge match. Fix: a **separate parent-scoped TableView** that carries the bridge joins; leave the general view
197
+ alone. Example (2026-10-17): `desk-customer-locations` (Locations → Locations_Customers → Customers, read only with
198
+ `Customers.uuid = <customer>`) beside the unchanged `desk-locations` (`dbchanges2/Client/2026-10-17b`). A 1:0..1 join
199
+ (single FK each hop) is safe to add to a shared view.
200
+
191
201
  ### Context RecordFieldSettings are OPTIONAL for a joined column to render
192
202
 
193
203
  `meta()` looks up `contextRecordFieldId`-scoped settings (`DefaultRecordFieldSettings` plus the Client / Persona / User / Language `RecordFieldSettings` tables) only to decide **which settings row supplies the column label**. When no context row exists for `(recordFieldId, contextRecordFieldId)`, it nulls `contextTable` / `contextField` and falls back to the field's own settings. A new joined column therefore does **not** require adding `RecordFieldSettings` rows just to appear — those rows are only needed to control the **header text** (see [page-meta-context-field-settings](./page-meta-context-field-settings.md), which is also where the "header renders as a camelCase slug" fallback is explained).
@@ -214,6 +224,10 @@ Core-level reference ids used (identical across all clients and environments): *
214
224
  - **An unused join can already exist.** Confirm before inserting; a duplicate join of the same table is what breaks chained joins.
215
225
  - **An INNER join to an EMPTY FK empties the whole grid with NO error.** Third failure mode: an inner join on an FK that is unpopulated for the tenant drops every row (meta looks fine, data returns 0). Use LEFT OUTER for an optional FK, or verify FK population before choosing INNER.
216
226
  - **`PurchaseOrderItems` has no `itemId` — it reaches the catalog via `vendorItemId → VendorItems → Items`.** Converting a sales-order-item view (which joins Items directly) to purchase-order-items needs an extra **VendorItems** hop inserted before Items, with the Items join repointed to `VendorItems.itemId`. Don't just recolumn.
227
+ - **Never add a many-to-many (bridge) OUTER join to a shared view** — every listing on that view gets duplicate rows. Use a separate parent-scoped view.
228
+
229
+ ## Change history
230
+ - 2026-10-17 — Many-to-many join in a shared view duplicates rows; use a parent-scoped view (TRUE-82870). (jcardinal)
217
231
 
218
232
  ## Related
219
233
 
@@ -156,6 +156,8 @@ VALUES (4188, '<pre-generated v4 uuid>', 219, 'serviceAddressId');
156
156
 
157
157
  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.
158
158
 
159
+ **`/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.
160
+
159
161
  #### The next free `id` is NOT the same in every environment
160
162
 
161
163
  The rule above keeps *existing* ids aligned, but each environment's `Core` has drifted independently, so **the next free value differs**. Verified 2026-08-17: the same new `ServiceRequests._serialNumbers` recordField took id **2483 in production** and **2766 in dev-sandbox**. Consequences:
@@ -398,5 +400,6 @@ The extra `SELECT … FROM ( … ) existing` layer both dodges error 1093 and pi
398
400
 
399
401
  ## Change history
400
402
 
403
+ - 2026-10-17 — /finish reports central ids used + next free (TRUE-82889). (jcardinal)
401
404
  - 2026-10-17 — Second same-folder file also allowed for interceptor rows that must run after the code deploy (TRUE-82889). (jcardinal)
402
405
  - 2026-10-13 — Added rule 15: no MySQL `CHECK` constraints (TRUE-82715). (jcardinal)
@@ -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)_ — 101 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
24
+ - **_underscore** (_Underscore) _(framework core)_ — 103 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) — 26 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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.1063",
3
+ "version": "1.0.1065",
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",
@@ -69,6 +69,9 @@ rows for the developer to write by hand.
69
69
  4. **Put verify SELECTs at the end of the file.** After the developer runs it, Claude runs
70
70
  those SELECTs itself (local DB, or `toga-db` read-only) and reports the result in plain
71
71
  words. Never ask the developer to read verify output.
72
+ 5. **Central ids at `/finish`** (team rule, 2026-10-17, jcardinal). When a ticket inserts
73
+ `Core.Records` / `Core.RecordFields` rows, the `/finish` report always lists the ids used
74
+ and the next free ids.
72
75
 
73
76
  ## No running commentary — one compiled report at the end (team rule, 2026-10-15)
74
77
 
@@ -460,6 +460,8 @@ CU-<internalId>
460
460
 
461
461
  ## Migration steps
462
462
  - <dbchanges2 file + database + order, config changes, queue registration — or "None">
463
+ - Central ids: <Records / RecordFields ids used, then "next free: Records <n>, RecordFields <n>"
464
+ — or "none used"> (dbchanges2 PRs only; same data as the Step 17 block)
463
465
 
464
466
  ## Review summary
465
467
  **Risk: <LEVEL>**
@@ -578,10 +580,27 @@ developer the list name and leave the status alone.
578
580
  Short list:
579
581
 
580
582
  1. KB: publish status (Step 5).
581
- 2. Ticket fields changed: before → after with the reason, one line each (or "none").
582
- 3. PRs: one url per repo, and which were created vs. updated.
583
- 4. PM comment: posted (yes/no), with the posted text.
584
- 5. Status: `development finished` (or why not).
585
- 6. Anything manual the developer must still do — as a numbered list.
583
+ 2. **Central ids — REQUIRED, always present, never skipped** (team rule, 2026-10-17). Its own
584
+ block with a clear heading, right after the KB line: the developer must copy the next ids
585
+ into Tools → Developers → Central IDs.
586
+ - Source: grep the ticket branch's dbchanges2 `Core/` SQL files for `INSERT INTO Records` /
587
+ `INSERT INTO RecordFields` ids. Confirm on the local DB:
588
+ `SELECT id, … FROM Core.Records WHERE id IN (…)`, the same for `Core.RecordFields`, and
589
+ `SELECT MAX(id)` on both.
590
+ - List every id: `Records <id> → <route>`, `RecordFields <id> → <route>.<field>`.
591
+ - Then: `Next free: Records <n>, RecordFields <n>` — n = the higher of (this ticket's top
592
+ id, local `MAX(id)`) + 1. Central IDs stays the source of truth.
593
+ - None inserted → write `Central ids: none used by this ticket.`
594
+ ```
595
+ **Central ids**
596
+ - Records 412 → desk/tickets
597
+ - RecordFields 9031 → desk/tickets.priority
598
+ Next free: Records 413, RecordFields 9032
599
+ ```
600
+ 3. Ticket fields changed: before → after with the reason, one line each (or "none").
601
+ 4. PRs: one url per repo, and which were created vs. updated.
602
+ 5. PM comment: posted (yes/no), with the posted text.
603
+ 6. Status: `development finished` (or why not).
604
+ 7. Anything manual the developer must still do — as a numbered list.
586
605
 
587
606
  After that, if the thread may continue another day, suggest `/session-save <slug>`.