toga-ai 1.0.294 → 1.0.296

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.
@@ -6,8 +6,8 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-25
10
- owners: ["jcardinal"]
9
+ updated: 2026-07-09
10
+ owners: ["jcardinal", "bala"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - api2/Component/Api/V2/Response/Response.php
@@ -54,8 +54,27 @@ PersonaApp, UserGlobal, UserApp — each overriding the previous when set. A Cli
54
54
  `isOverridable = 0` **locks** the value against all later layers. For the persona layers, the **first
55
55
  persona** (in the user's persona order) that sets it wins. The resolved code is validated against
56
56
  `Languages.code` (fallback `en`), embedded in the JWT `id.language` claim, and echoed on every
57
- response as `audience.language`. API-credential auth gets no language. Token refresh copies the claim,
58
- so a language change requires re-authentication.
57
+ response as `audience.language`. API-credential auth gets no language.
58
+
59
+ **Language re-resolution on token refresh (user tokens only).** `/auth/refresh` now re-resolves the
60
+ user's language and embeds it in the newly minted tokens, so a language switch takes effect on the
61
+ next hourly refresh **without a full re-login** (previously the refresh path copied the decoded JWT
62
+ payload verbatim and only reset `exp`/`iat`, so a language change was invisible until re-auth). The
63
+ re-resolution runs **only** for user tokens — it reads `id.client.user.uuid` off the token and skips
64
+ entirely for API-credential and public tokens (no language claim). It rebuilds the resolution context
65
+ from the existing token — loads `_Model_Client_User` by `uuid` (→ `userId`), `_Model_Core_App` by
66
+ `id.app.uuid` (→ `appId`), and takes `personaIds` from `id.client.personas` — then calls the **same**
67
+ private `resolveSettingValue(slug: _Model_Core_Setting::SLUG_LANGUAGE, context: {appId, personaIds,
68
+ userId})` helper the login path uses (same 8-layer cascade; the client DB is already registered at this
69
+ point). The resolved code is validated against `_Model_Client_Language` (load by `code`):
70
+ - **Valid** → sets `$this->jwtPayload->id->language` to the resolved code.
71
+ - **Unset/empty** → defaults to `'en'`.
72
+ - **Invalid** (non-empty but absent from `Languages`) → **keeps the token's current language** and adds
73
+ a `WZ-4` warning (see below). It deliberately does **not** fail the refresh — a bad setting must not
74
+ log an active user out on their hourly refresh (contrast the login path, which hard-fails with EV-16).
75
+
76
+ Placement is load-bearing: the `id.language` mutation happens **after** client-DB registration and
77
+ **before** both the access and refresh tokens are signed, so both new tokens carry the updated language.
59
78
 
60
79
  **Invalid-language hard failure (auth time).** The resolved code is validated against the client's
61
80
  `Languages` table. An **unset/empty** setting defaults to `en` (no error). But a **non-empty** code
@@ -64,6 +83,14 @@ that does **not** exist in `Languages` now **fails authentication** with HTTP 40
64
83
  with the offending `language` code in the message `identifiers` — instead of silently falling back to
65
84
  `en`. A misconfigured language must surface loudly, not silently degrade.
66
85
 
86
+ **Invalid-language soft warning (refresh time).** On the refresh path an invalid code is handled with a
87
+ distinct, non-fatal WARNING instead of EV-16: `WZ-4 DEFINED_MESSAGE_WARNING_INVALID_LANGUAGE_SETTING`
88
+ (type WARNING, group VALIDATION, `{language}` identifier placeholder; defined in `Response.php`, added
89
+ via `addDefinedMessage(...)` so the warning has a stable catalogued code). Because it is a WARNING it
90
+ carries no `status` key and does not change the response status — the refresh succeeds and the token
91
+ keeps its current language. This is deliberately softer than EV-16 (status 400), which would fail the
92
+ refresh and log the user out.
93
+
67
94
  **Auth-mint status finalization (subtle).** The auth success path sets `status = 201` and attaches the
68
95
  tokens. EV-16 is signalled by a local flag `$invalidLanguageSetting` set at the resolution site; the
69
96
  finalization branches `if (empty($invalidLanguageSetting))` → `201` + tokens, else
@@ -134,6 +161,9 @@ None — uniform across all clients. The sidecar table + ACL ship via `dbchanges
134
161
  `__get` — `isset()`/`?? null` always read false/null. See
135
162
  [_Model magic-field access](../../_underscore/features/model-magic-field-access.md); this was the
136
163
  cause of the uuid not appearing in the fallback warning.
164
+ - **Refresh re-resolution is user-token-only.** If you extend the JWT with new audience-shaped claims,
165
+ remember the refresh path rebuilds context from the *existing* token (uuid/app-uuid/personas) — it
166
+ does not re-read anything the token doesn't carry.
137
167
  - Migrations not yet executed at time of writing; needs live verification.
138
168
 
139
169
  ## Change history
@@ -149,6 +179,14 @@ None — uniform across all clients. The sidecar table + ACL ship via `dbchanges
149
179
  record `uuid` and dedupes per-record (`recordFieldId:uuid`). Documented the `isOverridable = 0`
150
180
  client-global lock as a config gotcha. Discovered the `_Model` `__isset` gap behind the missing-uuid
151
181
  bug (see linked _underscore doc). Live-tested on the local Compass DB. (jcardinal)
182
+ - 2026-07-09 — `/auth/refresh` now re-resolves the user's language (same `resolveSettingValue` cascade as
183
+ login) and embeds it in the newly minted access + refresh tokens, so a language switch applies on the
184
+ next refresh with no re-login (previously refresh copied the JWT payload verbatim, resetting only
185
+ `exp`/`iat`). User-tokens only (skips API-credential/public — no language claim); mutates `id.language`
186
+ after client-DB registration and before signing both tokens. Invalid code on refresh is a **soft**
187
+ non-fatal `WZ-4 WARNING_INVALID_LANGUAGE_SETTING` (new constant in `Response.php`) that keeps the
188
+ token's current language — vs. login's hard EV-16 400 — so a bad setting can't log an active user out.
189
+ (TRUE-80064, bala)
152
190
 
153
191
  ## Related docs
154
192
 
@@ -6,12 +6,13 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-07
10
- owners: ["bala"]
9
+ updated: 2026-07-09
10
+ owners: ["bala", "mhammontree"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  related:
14
14
  - ../architecture.md
15
+ - ../../../clients/aig/features/entitlement-intake.md
15
16
  ---
16
17
 
17
18
  ## Summary
@@ -57,6 +58,37 @@ rows on every write.
57
58
  If you need to link by a business key (like an employee XID), **resolve that key to a uuid in your
58
59
  caller first** (e.g. build a `key → uuid` map), then send `{uuid}`.
59
60
 
61
+ ## Per-API overrides (`Apis_RecordFields`)
62
+
63
+ The base identifier flags above (`Core.RecordFields.isIdentifier`) can be **overridden per API**
64
+ via rows in a tenant's `Apis_RecordFields` table, read by the V2 resolver's override block
65
+ (`V2.php` ~6926–6936; child policy in `getChildPolicy()` ~8019–8041; loader ~8526–8538). Two
66
+ columns matter:
67
+
68
+ - **`overrideIsIdentifier`** — overrides whether the field is a searchable identifier for that API.
69
+ - **`overrideChildPolicy`** — overrides link-vs-create for the nested record (e.g. `MATCH_CREATE`
70
+ = match an existing child by identifier, else create it).
71
+
72
+ > **Trap: a `NULL` `overrideIsIdentifier` is read as "not an identifier", not "no opinion".**
73
+ > The override block evaluates `!$row->overrideIsIdentifier`, so a `NULL` **unsets** the field
74
+ > from the API's searchable identifiers even when the base `Core.RecordFields` row has
75
+ > `isIdentifier=1`. Any `Apis_RecordFields` row created **only** to set a child policy
76
+ > (leaving `overrideIsIdentifier` NULL) will silently strip that field as an identifier. The
77
+ > nested object then has no searchable identifier and the write fails **EV-12 VALIDATION**
78
+ > (`V2.php:7094`) — the `MATCH_CREATE` that should have saved it never runs on that path.
79
+ > Correct the data with an explicit `overrideIsIdentifier = 1`, or (systemic fix, not yet done)
80
+ > change the code so only an explicit `0` removes an identifier and `NULL` means "no override".
81
+
82
+ **Build divergence warning.** The `Apis_RecordFields` override feature (reading
83
+ `overrideIsIdentifier`/`overrideChildPolicy`) was introduced 2026-03-20 (api2 commit `624146d`
84
+ "Cache custom fields and API override lookups"). Environments on **older** builds ignore the
85
+ override and keep the base identifier, so a tenant with identical Core metadata + override rows +
86
+ data can behave differently across environments purely by build version. A NULL-`overrideIsIdentifier`
87
+ override row that works on prod (older build) will start throwing EV-12 once prod gets the newer
88
+ build — audit such rows before/with the deploy. First seen in AIG (TRUE-79978): prod resolved,
89
+ beta/QA threw EV-12 on the injected `entitlementFulfillmentType` — see
90
+ [AIG entitlement intake](../../../clients/aig/features/entitlement-intake.md).
91
+
60
92
  ## Gotcha
61
93
 
62
94
  - **Nested write with only a non-identifier field silently creates duplicates.** This is a
@@ -68,6 +100,11 @@ caller first** (e.g. build a `key → uuid` map), then send `{uuid}`.
68
100
 
69
101
  ## Change history
70
102
 
103
+ - 2026-07-09 — Documented the per-API `Apis_RecordFields` override layer (`overrideIsIdentifier`,
104
+ `overrideChildPolicy`) and the trap that a `NULL` `overrideIsIdentifier` is read as "not an
105
+ identifier" (`!$row->overrideIsIdentifier`) — an override row set only for a child policy
106
+ silently strips the field as an identifier → EV-12 at `V2.php:7094`. Added the build-divergence
107
+ warning (override feature added 2026-03-20, commit `624146d`). Surfaced by AIG TRUE-79978. (mhammontree)
71
108
  - 2026-07-07 — Documented that api2 nested-relationship writes match an existing child **only** by
72
109
  identifier (`uuid`); a non-unique custom field is not a match key and forces a new-record insert.
73
110
  Correct pattern is `contact: {uuid}` (resolve business keys to uuid in the caller). Surfaced by
@@ -78,3 +115,5 @@ caller first** (e.g. build a `key → uuid` map), then send `{uuid}`.
78
115
  - [api2 architecture](../architecture.md) — the V2 CRUD engine (`processRoutePairs`).
79
116
  - [Prudential device import + contact linking](../../../clients/prudential/features/device-information-import-and-contact-linking.md)
80
117
  — the production incident that exposed this behavior.
118
+ - [AIG entitlement intake](../../../clients/aig/features/entitlement-intake.md) — the TRUE-79978
119
+ EV-12 that exposed the `Apis_RecordFields` NULL-`overrideIsIdentifier` trap + build divergence.
@@ -5,12 +5,13 @@ project: API
5
5
  client: aig
6
6
  type: client-feature
7
7
  status: active
8
- updated: 2026-06-19
8
+ updated: 2026-07-09
9
9
  owners: ["mhammontree"]
10
10
  files:
11
11
  - _underscore/Model/Aig/Entitlement.php
12
12
  - dbchanges2/Client_Aig/2026-06-18a - TRUE-79534 AIG SaleItem codes.sql
13
13
  related:
14
+ - 2.0/apps/api2/features/nested-relationship-writes.md
14
15
  - 2.0/apps/api2/architecture.md
15
16
  - 2.0/apps/dbchanges2/architecture.md
16
17
  - 2.0/apps/_underscore/architecture.md
@@ -42,7 +43,9 @@ the FK can't resolve and intake fails with **"Missing AIG item ID."** Keeping
42
43
  - reads `partNumber = payload->saleItem->partNumber` and runs
43
44
  `SELECT id FROM Items WHERE partNumber = '{partNumber}' LIMIT 1` to get `$itemId`;
44
45
  - uses `$itemId` to load fulfillment **types** (`Items_EntitlementFulfillmentTypes`) and
45
- **methods** (`Items_EntitlementFulfillmentMethods`) and injects them into the payload.
46
+ **methods** (`Items_EntitlementFulfillmentMethods`) and injects them into the payload as
47
+ **name-only** related objects (e.g. `entitlementFulfillmentType: {name: "Repair"}`). AIG
48
+ never sends `entitlementFulfillmentType`; it is populated entirely by this interceptor.
46
49
  3. The V2 engine resolves `Entitlement.saleItemId` (FK → `_Model_Client_Item`) from that same
47
50
  `saleItem.partNumber`. **No matching `Items` row → unresolvable FK → "Missing AIG item ID".**
48
51
  4. `postPost` then emails the contact a Staples Protection Plan registration link
@@ -118,9 +121,28 @@ this interceptor or use this dual-purpose Items pattern.
118
121
  codes are **not** in the SaleItemID spreadsheet and must be sourced separately if missing.
119
122
  - **No unique key on `Items.partNumber`** — use an anti-join for idempotent loads, not
120
123
  `INSERT IGNORE`.
124
+ - **An `entitlementFulfillmentType` EV-12 VALIDATION error is NOT a bad client payload —
125
+ it's this interceptor's injected field failing to resolve.** AIG never sends that field;
126
+ the interceptor injects it as `{name: "Repair"}`. The error means the V2 resolver could not
127
+ find a searchable identifier for the injected object. Root cause (TRUE-79978) was an
128
+ `Apis_RecordFields` override row that stripped `name` as an identifier — see
129
+ [api2 nested-relationship writes → Per-API overrides](../../../2.0/apps/api2/features/nested-relationship-writes.md).
130
+ - **Empty `saleItem.partNumber` silently resolves to the wrong item.** The lookup is
131
+ `WHERE partNumber = '{$partNumber}' LIMIT 1`; when `partNumber` is `""`, it matches item
132
+ `id=10` ("2YR Tablet") — the first row with an empty part number — which carries a "Repair"
133
+ fulfillment type. So a blank part number does not fail loudly; it injects a spurious
134
+ name-only fulfillment type. The interceptor should guard an empty/failed lookup before
135
+ injecting (not yet fixed).
121
136
 
122
137
  ## Change history
123
138
 
139
+ - 2026-07-09 — TRUE-79978: root-caused an `entitlementFulfillmentType` EV-12 on beta/QA. The
140
+ field is injected (name-only) by `prePost`; the beta `Client_Aig.Apis_RecordFields` override
141
+ row (apiId=2, recordFieldId=1202) had `overrideIsIdentifier=NULL` + `overrideChildPolicy=MATCH_CREATE`,
142
+ which the newer V2 build reads as "not an identifier", stripping `name` → no searchable id →
143
+ EV-12. Unblocked with a manual one-row beta data fix (`SET overrideIsIdentifier=1`); prod runs
144
+ an older build without the override feature so it still resolves. See the api2 feature doc for
145
+ the shared resolver behavior. (mhammontree)
124
146
  - 2026-06-19 — Documented intake flow; loaded 2,200 new SaleItem codes (1,700 STS `ASI-*`/`SM-*`
125
147
  + 500 unique numeric `SA`) into `Client_Aig.Items` via TRUE-79534, fixing "Missing AIG item ID"
126
148
  on the new `ASI-*` scheme. Follow-up to TRUE-79441. (mhammontree)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.294",
3
+ "version": "1.0.296",
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",