toga-ai 1.0.1067 → 1.0.1069

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.
@@ -22,8 +22,9 @@
22
22
  | [_Component_*/_Model_* project-namespace registration (autoloader) & backslash-qualify traps](features/component-model-namespace-registration.md) | Project-local `_Component_*`/`_Model_*` must declare the app namespace AND live in a `<Name>/<Name>.php` directory, or the autoloader fatals at runtime; open wh |
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
+ | [Contact related records — notes, files, contact types (slug), delete](features/contact-notes-files-types.md) | Shared Client-DB data model around a Contact (notes, files, typed contact types, delete); open before linking anything to a contact, keying on a contact type, o |
25
26
  | [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 |
27
+ | [Customer data model — tiers, industries, customer since, locations, files, notes](features/customer-locations-files-notes.md) | Shared Client-DB data model around a Customer (tier, industry, customer-since, site/contact counts, locations, files, notes); open before adding a customer fiel |
27
28
  | [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 |
28
29
  | [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 |
29
30
  | [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. |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-10-13
9
+ updated: 2026-10-17
10
10
  owners: ["jcardinal", "rgirish", "mhammontree", "ajean"]
11
11
  files:
12
12
  - _underscore/_underscore.php
@@ -436,7 +436,16 @@ multi-file UI components (`.php`/`.html`/`.css`/`.js`) invoked as `<_ComponentNa
436
436
  message/trace to the client if `display_errors` is on unconditionally. Confirm `display_errors` is
437
437
  disabled in production and that the framework's error surface returns a sanitized envelope (not a raw
438
438
  stack trace) to API consumers. Not yet done — tracked as a follow-up.
439
+ - **`class_exists('X')` throws, not false, for an unknown class.** It runs the `_Loader` autoloader,
440
+ which throws on a missing file → 500 EO-1. For optional PHP-extension classes (e.g. `Transliterator`,
441
+ intl is missing on local XAMPP) call `class_exists('\X', false)`. Live case:
442
+ `_Model_Client_CustomerLookup::deriveSlug()` (TRUE-82903).
443
+ - **A NOT NULL column's DB default never applies to a model INSERT.** `_Model` writes every declared
444
+ field explicitly, so a `FIELD_INTEGER` with no `FIELDOPT_DEFAULT` inserts NULL → MySQL 1048 on a
445
+ `NOT NULL DEFAULT 0` column. Declare `FIELDOPT_DEFAULT` on the model to match every NOT NULL column
446
+ default. Live cases: `CustomerTier` / `Industry.sortOrder` (TRUE-82903), `Cache Tables.safeRecordCount`.
439
447
 
440
448
  ## Change history
441
449
 
442
450
  - 2026-10-07 — Corrected tenant DB-name resolution: resolve `Core.Clients` id columns into `Core.Databases`; `clientIdentifier` is not the suffix (Compass). (mhammontree)
451
+ - 2026-10-17 — Gotchas: `class_exists` autoload throws; model INSERT ignores DB column defaults (TRUE-82903). (jcardinal)
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-17
9
+ updated: 2026-10-17
10
10
  owners: [snaredla, jcardinal, bala, apeterson, rgirish]
11
11
  files:
12
12
  - _underscore/Model.php
@@ -289,8 +289,13 @@ Keep the expression cheap, or expect callers to pay for it on every list.
289
289
  same-parent child inserts then deadlock (MySQL 1213). This is exactly how `PurchaseOrder._total`
290
290
  deadlocked the ASN path; see
291
291
  [save-cascade stored-field deadlocks](./model-save-parent-cascade-stored-field-deadlock.md).
292
+ - **Count calc fields are for listing columns only — not detail-tab counts.** A calc field skips the
293
+ child's row-ACL filters and cannot count across the Core vs Client cluster split. A detail tab's count
294
+ = the embedded tab table's own list total (`fields=uuid`, page size 1, `meta.totalRecordCount`). Full
295
+ rule: toga25-desk `docs/decisions/0153-*`, PLATFORM rule 37.
292
296
 
293
297
  ## Change history
298
+ - 2026-10-17 — Count calc fields: listing columns only; detail-tab counts use the list total (TRUE-82903). (jcardinal)
294
299
  - 2026-09-17 — Recorded a live instance of the `self::` trap: `_Model_Client_Item`'s four composite
295
300
  fields (`_qtyOnHand`, `_qtyAvailable`, `_qtyBackordered`, `_qtyOnOrder`) used **`self::`**, so a
296
301
  client subclass overriding a *leaf* `_qty*` method was silently ignored unless it re-declared all
@@ -0,0 +1,70 @@
1
+ ---
2
+ title: Contact related records — notes, files, contact types (slug), delete
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/Contact.php
13
+ - _underscore/Model/Client/ContactNote.php
14
+ - _underscore/Model/Client/Contacts/File.php
15
+ - _underscore/Model/Client/ContactType.php
16
+ - dbchanges2/Client/2026-10-17c - ContactsTypesFilesNotes.sql
17
+ - dbchanges2/Core/2026-10-17e - TRUE-82871 ContactsHiFiCore.sql
18
+ - dbchanges2/Core/2026-10-17f - TRUE-82871 ContactDeleteInterceptor.sql
19
+ related:
20
+ - ./customer-locations-files-notes.md
21
+ - ./record-hard-delete.md
22
+ - ./record-activity-timeline.md
23
+ - ./note-record-authorship.md
24
+ - ./contact-customer-required-internal-customer.md
25
+ - ../../toga25-blox/features/contact-channels-save.md
26
+ ---
27
+ Shared Client-DB data model around a Contact (notes, files, typed contact types, delete); open before linking anything to a contact, keying on a contact type, or deleting a contact.
28
+
29
+ ## Summary
30
+
31
+ Added 2026-10-17 (TRUE-82871, built for TOGa Desk, usable by any app). Copies the Customer notes/files pattern
32
+ ([customer related records](./customer-locations-files-notes.md)) — read that doc for the shared shape.
33
+
34
+ | Piece | Shape | Core ids |
35
+ |---|---|---|
36
+ | `ContactTypes.slug` | `VARCHAR(64)` unique; seeded `primary`, `technical`, `billing`, `executive` in every client DB | RecordFields 2815 (Records 169) |
37
+ | `Contacts_Files` | copy of `Customers_Files` | Records 385 `contact-files`, RecordFields 2816-2819 |
38
+ | `ContactNotes` | copy of `CustomerNotes`: write-once, server-stamped `authorUserId` | Records 386 `contact-notes`, RecordFields 2820-2825 |
39
+ | Delete | PRE DELETE interceptor on Records 37 | `Core/2026-10-17f` |
40
+
41
+ ## How it works
42
+
43
+ - **Contact types:** presets, filters, and code key on `slug`, never `name` (names are display copy). Badge color
44
+ comes from the Surface vocabulary `contact-type` (term value = slug) + neutral ThemeTokens — no color column.
45
+ An existing row named Primary/Technical/Billing/Executive got the slug instead of a duplicate row.
46
+ - **Inherent children:** `contacts → contact-files` (`contactFiles[]`), `contacts → contact-notes` (`contactNotes[]`),
47
+ both isParentTouched 1.
48
+ - **ContactNotes author:** `authorUserId` from `_Model::getRequestUserId()` in `save()`; no client user (API key,
49
+ worker) → 400; updates refused. See [note authorship](./note-record-authorship.md).
50
+ - **Client ACL:** granted to every role with a global read on contacts. 169: read (slug read-only). 385: CRUD.
51
+ 386: create + read, only `note` writable.
52
+ - **Activity:** root `contacts` → surface `contact-activity`; `contact-notes` in `CREATE_HIDDEN_ROUTES`
53
+ ([record activity timeline](./record-activity-timeline.md)).
54
+ - **Delete:** block / detach / cascade map in `Contact::relatedRows()` — full list in
55
+ [record hard delete](./record-hard-delete.md) (Contact rule). A blocked delete returns a 400 naming what links it.
56
+ - **Saving primary email/phone from a form:** blox `updateContactChannels` (see
57
+ [contact channels save](../../toga25-blox/features/contact-channels-save.md)).
58
+
59
+ ## Gotchas
60
+
61
+ - **Run order is Client first:** `Client/2026-10-17c` on every client DB → deploy `_underscore` → `Core/2026-10-17e`
62
+ and `f` (the Client file reads nothing from Core; Core rows point at models/tables that must already exist).
63
+ - **New `fileId` FK table → `FILE_REFERENCE_TABLES`.** `Contacts_Files` was added; without it a customer/case purge
64
+ could delete a Files row a contact still uses.
65
+ - **Client schemas drift** (e.g. `Client_True.TransferOrders` has no `contactId`) — the delete map probes optional
66
+ columns with `columnExists()`.
67
+ - **TableViewPresets are per-user** (`userId` NOT NULL) — "default preset views" from a design cannot be seeded.
68
+
69
+ ## Change history
70
+ - 2026-10-17 — Created: ContactTypes.slug + seeds, Contacts_Files, ContactNotes, contact activity root, contact delete (TRUE-82871). (jcardinal)
@@ -1,5 +1,5 @@
1
1
  ---
2
- title: Customer related records — primary location, Locations_Customers, files, notes
2
+ title: Customer data model — tiers, industries, customer since, locations, files, notes
3
3
  framework: "2.0"
4
4
  repo: _underscore
5
5
  project: _Underscore
@@ -10,39 +10,59 @@ updated: 2026-10-17
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - _underscore/Model/Client/Customer.php
13
+ - _underscore/Model/Client/CustomerLookup.php
14
+ - _underscore/Model/Client/CustomerTier.php
15
+ - _underscore/Model/Client/Industry.php
13
16
  - _underscore/Model/Client/Locations/Customer.php
14
17
  - _underscore/Model/Client/Customers/File.php
15
18
  - _underscore/Model/Client/CustomerNote.php
16
- - dbchanges2/Core/2026-10-17b - CustomerLocationsFilesNotesActivity.sql
19
+ - dbchanges2/Client/2026-10-17e - TRUE-82903 CustomerTiersIndustries.sql
20
+ - dbchanges2/Core/2026-10-17h - TRUE-82903 CustomerTiersIndustries.sql
17
21
  - dbchanges2/Client/2026-10-17b - CustomerLocationsFilesNotes.sql
18
- - dbchanges2/Core/2026-10-17d - CustomerNotesRecordFieldNames.sql
22
+ - dbchanges2/Core/2026-10-17b - CustomerLocationsFilesNotesActivity.sql
19
23
  related:
20
24
  - ./record-hard-delete.md
21
25
  - ./record-activity-timeline.md
22
26
  - ./note-record-authorship.md
23
27
  - ./tableview-joins.md
28
+ - ./calculated-sql-fields.md
24
29
  - ./model-save-parent-cascade-stored-field-deadlock.md
25
30
  - ./contact-customer-required-internal-customer.md
26
31
  - ./acl-permission-chain.md
32
+ - ../../api2/features/api-payload-interceptors.md
27
33
  ---
28
- 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.
34
+ Shared Client-DB data model around a Customer (tier, industry, customer-since, site/contact counts, locations, files, notes); open before adding a customer field, a customer lookup, or linking anything to a customer.
29
35
 
30
36
  ## Summary
31
37
 
32
- Added 2026-10-17 (TRUE-82870, built for TOGa Desk, usable by any app). All tables live in every `Client_*` DB.
38
+ Built for TOGa Desk (TRUE-82870, TRUE-82903), usable by any app. All tables live in every `Client_*` DB.
33
39
 
34
40
  | Piece | Shape | Core ids |
35
41
  |---|---|---|
36
42
  | `Customers.primaryLocationId` | FK → Locations, standard field | RecordFields 2799 |
37
43
  | `Customers._contactCount` | non-stored `FIELD_SQL` COUNT of Contacts by `customerId` | RecordFields 2800 |
44
+ | `Customers.customerTierId` | nullable FK → CustomerTiers | RecordFields 2826-2841 (with below) |
45
+ | `Customers.industryId` | nullable FK → Industries | |
46
+ | `Customers.dateCustomerSince` | `DATE` | |
47
+ | `Customers._siteCount` | non-stored `FIELD_SQL` COUNT of `Locations_Customers` rows | |
48
+ | `CustomerTiers` | lookup (name, slug, sortOrder, isActive) | Records 387 `customer-tiers` |
49
+ | `Industries` | lookup, same shape | Records 388 `industries` |
38
50
  | `Locations_Customers` | many-to-many bridge, `UNIQUE (locationId, customerId)` | Records 382 `location-customers` |
39
51
  | `Customers_Files` | copy of `Cases_Files`, `UNIQUE (customerId, fileId)` | Records 383 `customer-files` |
40
52
  | `CustomerNotes` | copy of `CaseNotes` minus stage: write-once, internal | Records 384 `customer-notes` |
41
53
 
42
- RecordFields 2801-2814 cover the three new records.
54
+ RecordFields 2801-2814 cover 382-384. SQL: `Client/2026-10-17e` + `Core/2026-10-17h` (tiers/industries), `Client/2026-10-17b` + `Core/2026-10-17b` (locations/files/notes).
43
55
 
44
56
  ## How it works
45
57
 
58
+ - **Customer lookups (`CustomerTiers`, `Industries`)** extend abstract `_Model_Client_CustomerLookup`. Its PRE
59
+ POST/PUT/PATCH/DELETE api2 interceptors: trim `name`; derive `slug` from `name`; duplicate slug → readable 400;
60
+ `sortOrder` = MAX+1 when not sent; delete blocked while any customer points at the row. A new customer lookup =
61
+ subclass + `customerColumn()` + `deleteBlockedMessage()`. Subclasses declare `FIELDOPT_DEFAULT` for every
62
+ NOT NULL column (`sortOrder` 0, `isActive` true) — the model writes NULL otherwise.
63
+ - **Tier colours are data, not code:** Surface vocabulary `customer-tier` (kind TYPE) + client ThemeTokens
64
+ `color.tier.<slug>`.
65
+ - **"Customer since" = `dateCustomerSince`.** Never use `dtCreated` for it (that is the row's insert time).
46
66
  - **Location of a customer:** use `primaryLocationId` (a real join). `_location` (city, state from the bill-to
47
67
  Address) is kept for its existing readers — do not build new features on it.
48
68
  - **Bridge, not `Locations.customerId`:** a location may belong to many customers (developer decision).
@@ -61,15 +81,19 @@ RecordFields 2801-2814 cover the three new records.
61
81
 
62
82
  ## Gotchas
63
83
 
84
+ - **`deriveSlug()` checks `Transliterator` with `class_exists('\X', false)`** — rule lives in
85
+ [_underscore architecture § Gotchas](../architecture.md#gotchas).
64
86
  - **Never OUTER-join a many-to-many bridge into a shared TableView.** blox sends every join of a view on every
65
87
  request (`buildJoinOptions`), so the general listing repeats each row once per bridge match. Make a
66
88
  separate parent-scoped view instead (rule lives in [tableview-joins](./tableview-joins.md)).
67
- - **`_contactCount` must stay non-stored** — a stored self-aggregate deadlocks the save cascade
68
- ([details](./model-save-parent-cascade-stored-field-deadlock.md)).
89
+ - **`_contactCount` / `_siteCount` must stay non-stored** — a stored self-aggregate deadlocks the save cascade
90
+ ([details](./model-save-parent-cascade-stored-field-deadlock.md)). They are for listing columns; detail-tab
91
+ counts come from the tab table's own list total (see [calculated fields](./calculated-sql-fields.md)).
69
92
  - **Assets link to a customer through Contacts** (`Units.contactId → Contacts.customerId`), not directly.
70
93
  - **Copied RecordFields need `name`.** `CaseNotes` RecordFields 2743-2749 have NULL `name` (not fixed), so the
71
94
  copy 2809-2814 came out NULL too; filled by `Core/2026-10-17d`. Set `name` on every new RecordField.
72
95
 
73
96
  ## Change history
97
+ - 2026-10-17 — Added CustomerTiers (387), Industries (388), `_Model_Client_CustomerLookup` interceptors, `customerTierId`, `industryId`, `dateCustomerSince`, `_siteCount` (TRUE-82903). (jcardinal)
74
98
  - 2026-10-17 — Filled NULL `name` on RecordFields 2809-2814 (Core/2026-10-17d). (jcardinal)
75
99
  - 2026-10-17 — Created: primaryLocationId, _contactCount, Locations_Customers, Customers_Files, CustomerNotes (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-17
10
10
  owners: [apeterson, jcardinal]
11
11
  files:
12
12
  - _underscore/Model/Client/SalesOrderNote.php
@@ -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) 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).
66
+ - **Exception — `CaseNotes` (2026-10-15), `CustomerNotes` and `ContactNotes` (2026-10-17) have a real author:** `authorUserId` stamped in `::save()` from `_Model::getRequestUserId()`. Copy that pattern for the real fix. The `save()` body is now copied 3 times (Case, Customer, Contact) — move it to a shared trait/base before a 4th copy. 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).
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 — ContactNotes added as third server-stamped-author note model; save() body is a consolidation candidate (TRUE-82871). (jcardinal)
70
71
  - 2026-10-17 — CustomerNotes added as a second server-stamped-author note model (TRUE-82870). (jcardinal)
71
72
  - 2026-10-15 — Noted CaseNotes as the first note model with a server-stamped author. (jcardinal)
72
73
 
@@ -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`, `customers` → `customer-activity`. Any other route → 400. **A new root = one map entry + its Surface
35
+ `service-request-activity`, `customers` → `customer-activity`, `contacts` → `contact-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`, `customer-notes`) are dropped — the host screen merges the notes feed.
48
+ `case-notes`, `service-request-notes`, `customer-notes`, `contact-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,6 +69,7 @@ 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 `contacts` + `contact-notes` to `CREATE_HIDDEN_ROUTES` (TRUE-82871). (jcardinal)
72
73
  - 2026-10-17 — Added root `customers` + `customer-notes` to `CREATE_HIDDEN_ROUTES` (TRUE-82870). (jcardinal)
73
74
  - 2026-10-17 — One-FK rule for parent links; no request id for grouping (TRUE-82889). (jcardinal)
74
75
  - 2026-10-15 — Added roots `units`, `cases`, `service-requests` + their note routes to `CREATE_HIDDEN_ROUTES` (TRUE-82836). (jcardinal)
@@ -11,6 +11,7 @@ owners: [jcardinal]
11
11
  files:
12
12
  - _underscore/Component/RecordDelete/RecordDelete.php
13
13
  - _underscore/Model/Client/Customer.php
14
+ - _underscore/Model/Client/Contact.php
14
15
  - _underscore/Model/Client/Ticket.php
15
16
  - _underscore/Model/Client/Case.php
16
17
  - _underscore/Model/Client/ServiceRequest.php
@@ -26,7 +27,7 @@ Map-driven hard delete of everything hanging off one record (block / detach / de
26
27
  `DELETE /v2/<route>/{uuid}` on an opted-in record runs a PRE DELETE interceptor that calls
27
28
  `_Component_RecordDelete::purge(['model' => static::class, 'id' => $id])`. `purge()` cleans the related
28
29
  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
+ Tickets, Cases, ServiceRequests, Customers, Contacts. The class DocBlock is the full contract — read it before editing.
30
31
 
31
32
  ## How it works
32
33
 
@@ -44,9 +45,22 @@ Tickets, Cases, ServiceRequests, Customers. The class DocBlock is the full contr
44
45
  - **Customer rule (Records 113, 2026-10-17):** block on Contacts / SalesOrders / PurchaseOrders; detach Tickets /
45
46
  ServiceRequests / Cases `customerId`; delete Customers_Catalogs, Customers_Subscriptions, Locations_Customers,
46
47
  Customers_Files, CustomerNotes rows; orphan Files from Customers_Files.
48
+ - **Contact rule (Records 37, interceptor `Core/2026-10-17f`):** block on Users, Tickets (`contactId`,
49
+ `reportedByContactId`, `assignedToContactId`, and `contactPhoneNumberId` via the contact's phone rows),
50
+ Tickets_Contacts, ServiceRequests.requestedForContactId, Units, SalesOrders (`contactId`, `requestedByContactId`),
51
+ PurchaseOrders, Entitlements, TransferOrders.contactId (optional). Detach the `contactId` author column of every
52
+ `*Notes` table (TransferOrderNotes optional) and Files.contactId (optional). Delete Contacts_Files (+ orphan Files),
53
+ ContactNotes, ContactAttempts, ContactMessagingOptIns, Campaigns_Contacts, Campaigns_MeetWithContacts,
54
+ Contacts_Tags, Contacts_Vendors, Contacts_Locations, Locations_Contacts, ContactAddresses / EmailAddresses /
55
+ PhoneNumbers. `preDelete` clears the contact's `primary*` pointers first (they and the children point at each other).
47
56
 
48
57
  ## Gotchas
49
58
 
59
+ - **Client schemas differ — probe optional tables/columns.** A map entry for a table or column missing in one
60
+ `Client_*` DB fails that client's delete. Contact checks with `columnExists()` (`SHOW TABLES` / `SHOW COLUMNS`,
61
+ read host off) before adding TransferOrders / TransferOrderNotes / Files `contactId` entries — e.g.
62
+ `Client_True` has TransferOrders without `contactId`. Do the same for any table not in every client DB.
63
+
50
64
  - **New table with a `fileId` FK → add it to `FILE_REFERENCE_TABLES`.** Missing it makes the Files DELETE fail on
51
65
  the FK and roll back every delete that touches a shared file (loud, not silent).
52
66
  - **Client override of `relatedRows()` must merge `parent::relatedRows()` key by key;** a client `preDelete`
@@ -57,4 +71,5 @@ Tickets, Cases, ServiceRequests, Customers. The class DocBlock is the full contr
57
71
  losing the link would lose meaning (orders, required FKs like `Contacts.customerId`).
58
72
 
59
73
  ## Change history
74
+ - 2026-10-17 — Contacts opted in (block / detach / cascade map, schema-drift probes); Contacts_Files added to FILE_REFERENCE_TABLES (TRUE-82871). (jcardinal)
60
75
  - 2026-10-17 — Created; Customers opted in, Customers_Files added to FILE_REFERENCE_TABLES (TRUE-82870). (jcardinal)
@@ -116,7 +116,7 @@ Page 1 of 25 still fetches exactly 25; page 254 fetches 500 per pass — ~13 rou
116
116
  - **MySQL `GET_LOCK` names cap at 64 chars and ERROR rather than truncate.** The original lock name was 80 chars.
117
117
  - **`_Query::fetchRows()` returns OBJECTS**, not arrays. CrossClient read them with array syntax throughout.
118
118
  - **A V2 LIST nests records under the route key** (`data: {"salesOrders": [...]}`); `parseClientResponse` required `data` to be a flat array and discarded every successful sub-response as unusable.
119
- - **`_Model` writes every declared field explicitly, so column defaults never apply.** `Tables.safeRecordCount` is NOT NULL and `buildCache` never assigned it. Likewise a Core column (`Records.ttlCache`) that exists in SQL but is not declared on the model makes `__get` throw.
119
+ - **`Tables.safeRecordCount` must be assigned in `buildCache`** — model INSERTs ignore DB column defaults (rule lives in [_underscore architecture § Gotchas](../../_underscore/architecture.md#gotchas)). Likewise a Core column (`Records.ttlCache`) that exists in SQL but is not declared on the model makes `__get` throw.
120
120
  - **Dangling foreign keys used to fail an entire listing.** `getFullModelData()` threw out of `_Model::initialize()` when a referenced row did not exist; one orphaned pointer killed the whole page. It now logs and expands the relationship to `null`. **This affects every REST listing/GET in api2, not just cross-client.**
121
121
  - **`empty()`/`isset()` on a `_Model` field is unreliable** and fails in the "looks unauthenticated" direction — see [_Model magic-field access](../../_underscore/features/model-magic-field-access.md).
122
122
  - **Switching the `Client` DB alias mid-request** needs the park/restore path — `_Database` keys live connection state by **alias**, not physical schema. See [Re-pointing a DB alias mid-request](../../_underscore/features/database-alias-repointing.md).
@@ -128,6 +128,10 @@ Page 1 of 25 still fetches exactly 25; page 254 fetches 500 per pass — ~13 rou
128
128
  requests must add `$api->cacheInvalidations['<recordId>:<clientId>'] = ['recordId' => …,
129
129
  'clientId' => …]` for each record type it touched, or cross-client listings serve stale rows.
130
130
  Example: `_Model_Client_CaseRecordRemoval` (TRUE-82889).
131
+ - **⚠ Fetch scope is either/or: `client=` scatter OR one tenant's own token — never both.** Sending
132
+ `client=` on another tenant's token makes `resolveScope()` fan out to THAT tenant's `Users_Clients`
133
+ (cross-tenant read). Seeded uuids also repeat across `Client_*` DBs, so multi-client UI rows must be
134
+ keyed by client uuid + record uuid. Full rule: toga25-desk ADR 0154, `GOTCHAS.md`.
131
135
  - **`curl_multi` busy-spin guard** — both multi loops `usleep(100)` when `curl_multi_select() === -1`.
132
136
  - **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.
133
137
 
@@ -141,4 +145,5 @@ Page 1 of 25 still fetches exactly 25; page 254 fetches 500 per pass — ~13 rou
141
145
  - [_Model magic-field access](../../_underscore/features/model-magic-field-access.md)
142
146
 
143
147
  ## Change history
148
+ - 2026-10-17 — Fetch scope is `client=` OR own token, never both; key multi-client rows by client + record uuid (TRUE-82903). (jcardinal)
144
149
  - 2026-10-17 — Internal-request writes must add their own `cacheInvalidations` (TRUE-82889). (jcardinal)
@@ -3,6 +3,7 @@
3
3
  | Doc | Summary |
4
4
  |-----|---------|
5
5
  | [TOGa Blox 2.5 — Architecture](architecture.md) | Shared React+TS SOURCE library for the new platform apps (new platform apps first; replaces toga-blox for all apps over time); read the ADR for full detail, not |
6
+ | [Contact primary email/phone save (updateContactChannels) — table calls only](features/contact-channels-save.md) | How a contact edit form saves the contact's primary email and phone without nested writes; use it (do not rebuild it) in any app that edits a contact's email/ph |
6
7
  | [Old blox (toga-blox) straight port inside toga25-blox](features/old-blox-port.md) | Old `@agilant/toga-blox` code copied unchanged into toga25-blox (blox-v1 layer); open before touching any old component, its checks/config, or merging Storybook |
7
8
  | [GitHub Actions → AWS role via OIDC (trust policy + sub claim)](workflows/github-actions-aws-oidc-role.md) | How a GitHub Actions job assumes an AWS IAM role with OIDC, and the `sub` claim trap; open before creating or debugging any GitHub-to-AWS role ("Not authorized |
8
9
  | [Moving an old app from @agilant/toga-blox onto toga25-blox](workflows/moving-an-old-app-onto-toga25-blox.md) | Planned steps to switch an old app off the `@agilant/toga-blox` npm package onto toga25-blox source with zero import edits; open before moving any old app. |
@@ -0,0 +1,45 @@
1
+ ---
2
+ title: Contact primary email/phone save (updateContactChannels) — table calls only
3
+ framework: "2.0"
4
+ repo: toga25-blox
5
+ project: TOGa Blox 2.5
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-10-17
10
+ owners: [jcardinal]
11
+ files:
12
+ - toga25-blox/src/forms/contact/updateContactChannels.ts
13
+ - toga25-blox/src/forms/contact/updateContactChannels.test.ts
14
+ related:
15
+ - ../../api2/features/nested-relationship-writes.md
16
+ - ../../_underscore/features/contact-notes-files-types.md
17
+ - ../../_underscore/features/contact-customer-required-internal-customer.md
18
+ ---
19
+ How a contact edit form saves the contact's primary email and phone without nested writes; use it (do not rebuild it) in any app that edits a contact's email/phone.
20
+
21
+ ## Summary
22
+
23
+ `updateContactChannels({registry, clientUuid, contactUuid, email?, phone?, signal?})` (exported from `forms`) saves
24
+ the primary `contact-email-addresses` / `contact-phone-numbers` child rows with plain table calls, as the contact's
25
+ own client. A channel left out is not touched.
26
+
27
+ ## How it works
28
+
29
+ Per channel, comparing the value when the form opened (`current`) to the form value (`next`):
30
+ 1. **Changed, has a primary** → `PUT` the primary child row's value.
31
+ 2. **Set, no primary yet** → `POST` a child row, then `PUT` the contact's `primaryContactEmailAddress` /
32
+ `primaryContactPhoneNumber` = `{uuid}` of the new row.
33
+ 3. **Cleared** → `PUT` the contact's primary link to `null`. The child row is never deleted.
34
+
35
+ ## Gotchas
36
+
37
+ - **Never save email/phone as nested children in one contact `PUT`.** Nested channel objects match by address text
38
+ tenant-wide — they can grab another contact's row or create duplicates
39
+ ([nested-relationship writes](../../api2/features/nested-relationship-writes.md)).
40
+ - **No api2 interceptor for this** — rejected as a hidden write; the form owns the sequence.
41
+ - The new row's uuid is read from the POST answer, never the contact's own uuid (an expanded parent in the answer must
42
+ not be linked as its own primary).
43
+
44
+ ## Change history
45
+ - 2026-10-17 — Created for the TOGa Desk contact edit drawer (TRUE-82871). (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)_ — 103 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
24
+ - **_underscore** (_Underscore) _(framework core)_ — 104 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)
@@ -34,7 +34,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
34
34
  - **toga25-supply** (TOGa 2.5 Supply) — 26 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
35
35
  - **toga25-iq** (TOGa IQ) — 1 doc(s) → [2.0/apps/toga25-iq/INDEX.md](2.0/apps/toga25-iq/INDEX.md)
36
36
  - **toga25-desk** (TOGa Desk 2.5) — 1 doc(s) → [2.0/apps/toga25-desk/INDEX.md](2.0/apps/toga25-desk/INDEX.md)
37
- - **toga25-blox** (TOGa Blox 2.5) — 4 doc(s) → [2.0/apps/toga25-blox/INDEX.md](2.0/apps/toga25-blox/INDEX.md)
37
+ - **toga25-blox** (TOGa Blox 2.5) — 5 doc(s) → [2.0/apps/toga25-blox/INDEX.md](2.0/apps/toga25-blox/INDEX.md)
38
38
  - **toga-blox** (TOGa Blox) — 15 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
39
39
 
40
40
  ## standalone framework
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.1067",
3
+ "version": "1.0.1069",
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",