toga-ai 1.0.1068 → 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.
@@ -24,7 +24,7 @@
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
  | [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 |
26
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. |
27
- | [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 |
28
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 |
29
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 |
30
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
@@ -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)
@@ -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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.1068",
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",