toga-ai 1.0.418 → 1.0.419

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,7 +22,7 @@
22
22
  | [_Model::save() vs raw _Query — no atomic conditional update](features/model-save-vs-query-atomic-update.md) | `_Model::save()` is a plain load-then-write ORM primitive and **cannot express an atomic conditional update** (an optimistic-concurrency / row-claim guard such | _underscore/Model.php, _underscore/Query.php |
23
23
  | [NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL](features/netsuite-rest-client.md) | `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive every worker2/api2 NetSuite caller uses for record GETs, Suit | _underscore/Component/Api/Netsuite/Netsuite.php |
24
24
  | [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php |
25
- | [Persona Name Translation (PersonaTranslations sidecar)](features/persona-name-translation.md) | Serves Persona **names** in multiple languages by adding a per-language **sidecar** table `PersonaTranslations`, reusing the platform's existing metadata-driven | _underscore/Model/Client/PersonaTranslation.php, dbchanges2/Client/2026-07-22a - PersonaTranslations.sql, dbchanges2/Core/2026-07-22a - PersonaTranslationsRecord.sql, dbchanges2/Client/2026-07-22b - PersonaTranslationsAcl.sql, dbchanges2/Client_CompassCanada/2026-07-22a - PersonaTranslationsFrench.sql |
25
+ | [Persona Name Translation (PersonaTranslations sidecar)](features/persona-name-translation.md) | Serves Persona **names** in multiple languages by adding a per-language **sidecar** table `PersonaTranslations`, reusing the platform's existing metadata-driven | _underscore/Model/Client/PersonaTranslation.php, dbchanges2/Client/2026-07-22b - PersonaTranslations.sql, dbchanges2/Core/2026-07-22a - PersonaTranslationsRecord.sql, dbchanges2/Client/2026-07-22c - PersonaTranslationsAcl.sql, dbchanges2/Client_CompassCanada/2026-07-22 - PersonaTranslationsFrench.sql, toga2-commerce/src/pages/Account/view/MySettingsView.tsx |
26
26
  | [Recursive Item Fulfillments (upstream mirroring)](features/recursive-item-fulfillments.md) | In a multi-tier supply chain a sales order (SO) spawns a purchase order (PO) that becomes another SO downstream, and so on. | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillmentItem.php, _underscore/Model/Client/ItemFulfillmentItemUnit.php, _underscore/Model/Client/ItemFulfillmentPackage.php, _underscore/Model/Compass/AdvanceShippingNotice.php, dbchanges2/Core/2026-02-13 - 75601 - RecursiveItemFulfillmentCreation.sql, dbchanges2/Core/2026-06-04 - RecursiveItemFulfillmentPut.sql, dbchanges2/Client_Compass/2026-07-02a - FixSA133377TrackingSerialAndDuplicateIF.sql |
27
27
  | [Surface Resolver (_Model_Core_Surface::resolve — replaces Page::meta)](features/surface-resolver.md) | The runtime for the platform-wide **Surface** UI presentation layer: 9 `_underscore` models plus a cached resolver, `_Model_Core_Surface::resolve(&$api, string | _underscore/Model/Core/Surface.php, _underscore/Model/Client/AclRecordScript.php, _underscore/Model/Core/RecordScript.php, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Quad/2026-07-01a - GrantSurfacesMetaGroupScriptAcl.sql, dbchanges2/Client_CompassCanada/2026-07-01a - GrantSurfacesMetaGroupScriptAcl.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Client_Compass/2026-07-15f - SalesOrderRecordActionsRemoveDeadConfigRuleOverrides.sql, dbchanges2/Core/2026-07-17h - Update - ClearApprovalsFilterButtonConfig.sql, dbchanges2/Client_Compass/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Compass/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_Quad/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Quad/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, _underscore/Model/Core/SurfaceElement.php, _underscore/Model/Core/Action.php, _underscore/Model/Core/Vocabulary.php, _underscore/Model/Core/VocabularyTerm.php, _underscore/Model/Core/Message.php, _underscore/Model/Client/SurfaceOverride.php, _underscore/Model/Client/MessageTranslation.php, _underscore/Model/Client/ThemeToken.php, _underscore/Model/Core/Page.php, api2/Component/Api/V2/V2.php |
28
28
  | [Table-View Hyperlink Columns (meta → ACL → computed URL → render)](features/tableview-hyperlink-columns.md) | Any 2.0 table-view column can render its value as a clickable link instead of plain text. | _underscore/Model/Client/TableView.php, _underscore/Model/Client/TrackingNumber.php, api2/Component/Api/V2/V2.php, toga2-supply/src/api/toga.ts, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/formatTableData.tsx, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/convertData.tsx, toga2-supply/src/components/ui/Tables/hooks/useDataTableState.tsx, dbchanges2/Client/2026-07-20 - TrackingNumberHyperlinkAndFieldPermission.sql |
@@ -6,14 +6,15 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: draft
9
- updated: 2026-07-22
10
- owners: ["tcox"]
9
+ updated: 2026-07-23
10
+ owners: ["tcox", "bala"]
11
11
  files:
12
12
  - _underscore/Model/Client/PersonaTranslation.php
13
- - dbchanges2/Client/2026-07-22a - PersonaTranslations.sql
13
+ - dbchanges2/Client/2026-07-22b - PersonaTranslations.sql
14
14
  - dbchanges2/Core/2026-07-22a - PersonaTranslationsRecord.sql
15
- - dbchanges2/Client/2026-07-22b - PersonaTranslationsAcl.sql
16
- - dbchanges2/Client_CompassCanada/2026-07-22a - PersonaTranslationsFrench.sql
15
+ - dbchanges2/Client/2026-07-22c - PersonaTranslationsAcl.sql
16
+ - dbchanges2/Client_CompassCanada/2026-07-22 - PersonaTranslationsFrench.sql
17
+ - toga2-commerce/src/pages/Account/view/MySettingsView.tsx
17
18
  related:
18
19
  - ./assortment-name-translation.md
19
20
  - ../../api2/features/language-translation-layer.md
@@ -31,25 +32,28 @@ pattern. English stays in `Personas.name`; the sidecar holds only non-English ov
31
32
  English fallback). Because the translation layer is fully metadata-driven (see
32
33
  [Language Translation Layer](../../api2/features/language-translation-layer.md)), wiring this up
33
34
  required **no api2 PHP logic change**: `GET /personas` names flow through `getTranslatedFieldValue`
34
- at the top-level serialization sites. Compass Canada's French (fr-CA) persona names are the first
35
- (and so far only) intended consumer.
36
-
37
- > **Status (2026-07-22): DRAFTED / DEFERRED — not committed, not run.** All five artifacts live in
38
- > local working trees only; the developer explicitly deferred the backend/DB changes this session.
39
- > `sql-reviewer` verdict was **SAFE TO MERGE (0 critical)**. See **Rollout / pre-run verification**
40
- > before executing — several ids and row-matches are *assumed* and must be confirmed against the live
41
- > DB first. DB verification could not be completed this session: all RDS endpoints (prod readers,
42
- > global, sandbox) are VPC-restricted from the dev machine — needs VPN or the claude.ai TOGa DB
43
- > connector.
35
+ at the top-level serialization sites. Only `Personas.name` is translatable — `Personas.number` is a
36
+ code, not display text, and is left untranslated. Compass Canada's French (fr-CA) persona names are
37
+ the first (and so far only) intended consumer.
38
+
39
+ > **Status (2026-07-23): backend artifacts FINALIZED (ids verified against prod), NOT yet run;
40
+ > fr-CA seed values PENDING business sign-off.** The Core ids were re-based this session against a
41
+ > confirmed prod snapshot — `MAX(Core.Records) = 346`, `MAX(Core.RecordFields) = 2462` at capture
42
+ > time — and the sidecar now takes **Record 348 / RecordFields 2469–2473**, following the
43
+ > `NotificationTranslations` sidecar (347 / 2463–2468) that landed just ahead of it. The five
44
+ > artifacts still live in working trees (not deployed). The French persona names are **proposed
45
+ > translations that still need Compass business sign-off** — brand/sector proper nouns (Eurest,
46
+ > Chartwells, ESFM, Foodbuy) must be confirmed before the seed is run. Deploy order:
47
+ > **table → record → acl → French backfill.**
44
48
 
45
49
  ## Key files / entry points
46
50
 
47
51
  - `_underscore/Model/Client/PersonaTranslation.php` — the sidecar model
48
52
  (`_Model_Client_PersonaTranslation`), a declarative copy of `AssortmentTranslation.php`.
49
- - `dbchanges2/Client/2026-07-22a - PersonaTranslations.sql` — the sidecar table (every client DB).
53
+ - `dbchanges2/Client/2026-07-22b - PersonaTranslations.sql` — the sidecar table (every client DB).
50
54
  - `dbchanges2/Core/2026-07-22a - PersonaTranslationsRecord.sql` — Core record + fields + the field link.
51
- - `dbchanges2/Client/2026-07-22b - PersonaTranslationsAcl.sql` — Base-role ACL grant (every client DB).
52
- - `dbchanges2/Client_CompassCanada/2026-07-22a - PersonaTranslationsFrench.sql` — fr-CA seed.
55
+ - `dbchanges2/Client/2026-07-22c - PersonaTranslationsAcl.sql` — Base-role ACL grant (every client DB).
56
+ - `dbchanges2/Client_CompassCanada/2026-07-22 - PersonaTranslationsFrench.sql` — fr-CA seed.
53
57
 
54
58
  ## How it works
55
59
 
@@ -64,9 +68,12 @@ The mechanism itself is the data-driven translation layer documented in
64
68
  2. **Model.** `_Model_Client_PersonaTranslation` is the ORM model for the sidecar (declarative copy
65
69
  of `AssortmentTranslation.php`).
66
70
  3. **Core record registration.** `Core.Records` registers route `persona-translations` →
67
- model `\_Model_Client_PersonaTranslation` at record **id 346** (assumed next-free), with
68
- `Core.RecordFields` rows **2458–2462** for `id`, `uuid`, `personaId`, `languageId`, `name`, and an
69
- `UPDATE ... JOIN` that sets `translationRecordFieldId` on the `Personas.name` RecordField.
71
+ model `\_Model_Client_PersonaTranslation` at record **id 348** (verified next-free after
72
+ `NotificationTranslations` = 347), with `Core.RecordFields` rows **2469–2473** for `id`, `uuid`,
73
+ `personaId`, `languageId`, `name`, and an `UPDATE ... JOIN` that sets `translationRecordFieldId`
74
+ on the `Personas.name` RecordField (source route `personas`, field `name`). These ids are
75
+ **env-specific** — they follow prod's confirmed maxima at capture time (`Records` 346 /
76
+ `RecordFields` 2462) and the intervening `NotificationTranslations` record (347 / 2463–2468).
70
77
  4. **The field link (what makes it translate automatically).** The translatable `Personas.name`
71
78
  RecordField gets its `Core.RecordFields.translationRecordFieldId` set to the sidecar's `name`
72
79
  RecordField. The api2 V2 layer reads `translationRecordFieldId` for every field and automatically
@@ -76,57 +83,84 @@ The mechanism itself is the data-driven translation layer documented in
76
83
  (Contrast the assortment sidecar, which additionally needed a `join=`/joined-select fix — persona
77
84
  names are not surfaced via a joined-select read path, so that 5th-serialization-site concern does
78
85
  not apply here.)
79
- 5. **ACL.** A full ACL chain grants the **Base** role read/write on the new record. Because the
80
- record's `aclDatabase = 'CLIENT'`, the grant lives in each client DB — see
81
- [ACL Permission Chain](./acl-permission-chain.md).
86
+ 5. **ACL.** A full ACL chain grants the **Base** role CRUD on the new record (field perms: `id`/`uuid`
87
+ read-only, the rest writable). Because the record's `aclDatabase = 'CLIENT'`, the grant lives in
88
+ each client DB — see [ACL Permission Chain](./acl-permission-chain.md). The chain binds
89
+ `AclLogicGroups`/`AclLogicGroupExpressions` by **UUID** rather than `MAX(id)` — the newer, safer
90
+ pattern that avoids cross-wiring when ACL inserts are interleaved.
82
91
 
83
92
  ## Data model
84
93
 
85
94
  - `Client.PersonaTranslations` — `id, uuid, dtCreated, dtUpdated, personaId (FK Personas),
86
95
  languageId (FK Languages), name`; `UNIQUE(personaId, languageId)`; `utf8mb4_unicode_ci`. English
87
96
  stays in `Personas.name`; sidecar holds only non-English overlays (null → English fallback).
88
- - `Core.Records` **346** (assumed) = `persona-translations` (`aclDatabase = 'CLIENT'`);
89
- `Core.RecordFields` **2458–2462** (assumed) for id/uuid/personaId/languageId/name.
97
+ - `Core.Records` **348** = `persona-translations` (`aclDatabase = 'CLIENT'`);
98
+ `Core.RecordFields` **2469–2473** for id/uuid/personaId/languageId/name (env-specific — follow
99
+ `NotificationTranslations` 347 / 2463–2468, which follows prod max `Records` 346 / `RecordFields` 2462).
90
100
  - `Personas.name` RecordField → `translationRecordFieldId` points at the sidecar `name` RecordField.
101
+ Only `Personas.name` is translatable (`Personas.number` is a code, left as-is).
91
102
 
92
103
  ## Rollout / pre-run verification (do this BEFORE executing)
93
104
 
94
- The ids and several row-matches in these migrations are **assumptions from a repo scan**, not
95
- confirmed against the live DB. Before running:
96
-
97
- 1. **Verify next-free Core ids.** Confirm live `MAX(Core.Records.id) = 345` and
98
- `MAX(Core.RecordFields.id) = 2457` so that 346 and 2458–2462 are actually free. (Consistent with
99
- the feature-translation work, which used Records 343/344/345 + RecordFields 2442–2457.) If the live
100
- maxima differ, renumber the record/field ids in `PersonaTranslationsRecord.sql` and the ACL SQL.
101
- 2. **Verify the 3 out-of-band personas.** The fr-CA seed matches 30 personas by known `Personas.uuid`
102
- (from the 2025-12-19 Personas seed), plus **3 personas matched by exact English name** — *Admin
103
- Canada*, *MacBooks*, *Managers/VIPs* — that exist only in the live DB (no repo migration). Confirm
104
- those three exist with those exact names and that `Personas` has **no duplicate names** (the
105
- name-match would double-seed otherwise).
106
- 3. **Confirm the glossary is complete.** The business-glossary screenshot the fr-CA text came from was
107
- cut off after "Managers/VIPs → Gestionnaires/VIP" — confirm there are no additional personas below
108
- that line before treating the seed as complete.
105
+ Before running:
106
+
107
+ 1. **Core ids are re-based (env-specific).** Ids were confirmed against a prod snapshot this session —
108
+ `MAX(Core.Records) = 346`, `MAX(Core.RecordFields) = 2462` — so the sidecar takes Record **348** /
109
+ RecordFields **2469–2473**, immediately after the `NotificationTranslations` sidecar (347 /
110
+ 2463–2468). If the target env's maxima differ, renumber the record/field ids in
111
+ `PersonaTranslationsRecord.sql` and the ACL SQL.
112
+ 2. **French names need Compass business sign-off.** The fr-CA seed backfills **all 34 Canada personas**,
113
+ but the French text is **proposed**, not approved. Brand/sector proper nouns (Eurest, Chartwells,
114
+ ESFM, Foodbuy) in particular must be confirmed with Compass before running the backfill.
115
+ 3. **Confirm no duplicate persona names** if any rows are matched by name rather than uuid (a name-match
116
+ would double-seed).
109
117
 
110
118
  fr-CA is `languageId 2` (same as the item-feature translations — see the Compass Canada profile and
111
119
  `UserGlobalSettings.settingId = 2`).
112
120
 
113
121
  ## Frontend consumption (toga2-commerce)
114
122
 
115
- For the translated names to appear on the storefront **without a full reload** after a language
116
- switch, the persona React Query caches must be language-sensitive. The persona query keys
117
- `userPersonaDataOnLogin` and `userPersonaDataOnViewAs` were added to `LANGUAGE_SENSITIVE_QUERY_KEYS`
118
- in `src/queries/queries.ts` (toga2-commerce) so they refetch on language change — see
119
- [Client Fields](../../toga2-commerce/features/client-fields.md). This frontend change shipped this
120
- session; the backend above did not.
123
+ Getting the translated name to appear on the storefront **without a full reload** after a language
124
+ switch is subtler than cache-invalidation alone. The displayed persona name comes from
125
+ `user._personaOptions` in the **persisted Zustand `user` store** (localStorage), which is populated
126
+ **once at login** (`getLoginSettings` → `NavIconList`; `LoginSettingsSwitcher` only fetches
127
+ `if (!user._personaOptions)`) and is **not** driven by a live React Query. So adding the persona
128
+ query keys to `LANGUAGE_SENSITIVE_QUERY_KEYS` in `src/queries/queries.ts` is **not sufficient** —
129
+ those keys don't exist for `_personaOptions`, and invalidation cannot refresh a value that no live
130
+ query owns. (Before the fix, switching language in My Settings left the old-language persona name
131
+ on screen until a full re-login.)
132
+
133
+ **The fix (surgical, language-branch only, safe for all clients).** In
134
+ `src/pages/Account/view/MySettingsView.tsx`, `saveAllChanges` `case 'language'`: after
135
+ `invalidateLanguageSensitiveQueries` and after `refreshAuthTokens` (so the new JWT carries the new
136
+ `id.language`), explicitly **refetch `fetchUserPersona(user)`** with the refreshed token and assign
137
+ `userUpdates._personaOptions` (applied by the existing `setUser` spread). The refetch is wrapped in
138
+ its **own try/catch** so a persona-refresh failure never rolls back the just-saved language. The
139
+ canonical `_personaOptions` shape is `{ number, name }[]` (`globalTypes.ts`).
140
+
141
+ The **same latent gap still exists** in `components/LoginSettingsSwitcher/LoginSettingsSwitcher.tsx`
142
+ (the login-modal switcher), left unchanged this session by request. See
143
+ [Client Fields](../../toga2-commerce/features/client-fields.md) for the storefront language-switch
144
+ machinery. This frontend fix shipped this session; the backend sidecar above did not.
121
145
 
122
146
  ## Client variations
123
147
 
124
- - **Compass Canada** is the intended first consumer: `2026-07-22a - PersonaTranslationsFrench.sql`
125
- seeds fr-CA names for 33 personas (30 by uuid + 3 by exact English name). Deferred / not yet run —
126
- see the Compass Canada profile.
148
+ - **Compass Canada** is the intended first consumer: `2026-07-22 - PersonaTranslationsFrench.sql`
149
+ seeds fr-CA names for all **34** Canada personas. Not yet run; the French text is proposed and
150
+ needs Compass business sign-off — see the Compass Canada profile.
127
151
 
128
152
  ## Change history
129
153
 
154
+ - 2026-07-23 — Re-based the Core ids against a confirmed prod snapshot (max `Records` 346 /
155
+ `RecordFields` 2462): the sidecar now takes Record **348** / RecordFields **2469–2473**, following
156
+ the `NotificationTranslations` sidecar (347 / 2463–2468). ACL chain now binds
157
+ `AclLogicGroups`/`AclLogicGroupExpressions` by **UUID** (not `MAX(id)`). fr-CA seed expanded to all
158
+ **34** Canada personas (French text still **proposed**, needs Compass sign-off). Fixed the
159
+ storefront language switch: `_personaOptions` lives in the persisted Zustand `user` store (not a
160
+ live React Query), so `LANGUAGE_SENSITIVE_QUERY_KEYS` alone can't refresh it — `MySettingsView.tsx`
161
+ `case 'language'` now explicitly refetches `fetchUserPersona(user)` with the refreshed JWT (own
162
+ try/catch). The `LoginSettingsSwitcher` modal has the same gap, left unchanged by request. Backend
163
+ sidecar still not run. (bala)
130
164
  - 2026-07-22 — Drafted the `PersonaTranslations` sidecar end-to-end following the
131
165
  `AssortmentTranslations` pattern exactly: sidecar table + `_Model_Client_PersonaTranslation` model +
132
166
  Core record 346 / RecordFields 2458–2462 registration + `translationRecordFieldId` link on
@@ -137,12 +137,37 @@ no extra model load, output unchanged). This is why a metadata-only sidecar (e.g
137
137
  still needed an api2 code change to translate on the `join=` read path even though FK/top-level reads
138
138
  worked with no code change.
139
139
 
140
+ **Sidecar source-FK derivation.** `loadTranslationSidecar()` figures out which sidecar column holds
141
+ the source row's id **generically** — it takes the sidecar record's FK field that is **not**
142
+ `languageId` as the source FK, then loads the sidecar row by `(sourceFkField = sourceId, languageId)`
143
+ via `_Model::load()` (no JOIN), cached per source row. This is why a new sidecar needs no per-table
144
+ wiring in api2: as long as the sidecar has exactly `{sourceFkId, languageId, <translated fields>}`,
145
+ the layer discovers the source FK from metadata.
146
+
140
147
  **Write path.** On create and update, `extractTranslationWrites()` pulls translatable fields out of
141
148
  the write set for a non-base language (so the English source is never overwritten), and after the
142
149
  source row saves, `saveTranslationWrites()` upserts them into the sidecar for the current language.
143
150
  Nested-child translatable writes are NOT auto-redirected — use the dedicated `/v2/item-translations`
144
151
  endpoint for those.
145
152
 
153
+ **Adding a new translatable field is DB-metadata-only (no api2 edit) for top-level/FK reads.** The
154
+ whole layer is data-driven, so making a new field translatable requires **no PHP change in api2** when
155
+ the field is emitted at a top-level or FK serialization site (only the `join=`/joined-select read path,
156
+ site 5, ever needed a code change — see the joined-select note above). The recipe:
157
+
158
+ 1. Set `Core.RecordFields.translationRecordFieldId` on the source field's RecordField to point at the
159
+ sidecar's translated-field RecordField (an `UPDATE … JOIN` matching source route + field name).
160
+ 2. Create the `<Entity>Translations` sidecar table (`{ …, <entity>Id FK, languageId FK, <fields> }`,
161
+ `UNIQUE(<entity>Id, languageId)`) and its `_Model_Client_<Entity>Translation` model.
162
+ 3. Register the Core `Record` + `RecordFields` for the sidecar route.
163
+ 4. Grant the Base-role ACL chain on the new record (client DB).
164
+ 5. Backfill the non-English overlay rows.
165
+
166
+ `getTranslatedFieldValue()`/`shouldTranslate()` then substitute the sidecar value on read for
167
+ non-base languages automatically; English/API/public traffic is byte-identical. The
168
+ [Persona Name Translation](../../_underscore/features/persona-name-translation.md) sidecar is a
169
+ worked example of this recipe (and confirmed it needed **zero** api2 changes).
170
+
146
171
  ## Data model
147
172
 
148
173
  - `Client.ItemTranslations` — `id, uuid, dtCreated, dtUpdated, itemId (FK Items, RESTRICT),
@@ -239,6 +264,12 @@ None — uniform across all clients. The sidecar table + ACL ship via `dbchanges
239
264
 
240
265
  ## Change history
241
266
 
267
+ - 2026-07-23 — Documented (no code change) that the layer is fully generic: `loadTranslationSidecar()`
268
+ derives the source FK as "the sidecar FK that is not `languageId`" and loads by `(sourceFk, languageId)`
269
+ via `_Model::load()`, so adding a new translatable field is **DB-metadata-only** (set
270
+ `translationRecordFieldId` + sidecar table/model + Core record/fields + ACL + backfill) with **no api2
271
+ edit** for top-level/FK reads — only the `join=` read path (site 5) ever needed code. Confirmed via the
272
+ `PersonaTranslations` sidecar, which required zero api2 changes. (bala)
242
273
  - 2026-07-21 — Wired translation into the **joined-select-fields** path (the 5th and final read-path
243
274
  serialization site) in `V2.php` `processRoutePairs` (joined-select SQL build ~L3700-3800, serialization
244
275
  ~L4038-4060). `join=` fields (e.g. `Assortments.name`) previously copied the raw SQL column straight into
@@ -285,3 +316,5 @@ None — uniform across all clients. The sidecar table + ACL ship via `dbchanges
285
316
 
286
317
  - [ACL Permission Chain](./acl-permission-chain.md)
287
318
  - [api2 Architecture](../architecture.md)
319
+ - [Persona Name Translation](../../_underscore/features/persona-name-translation.md) — metadata-only
320
+ worked example (no api2 change).
@@ -6,7 +6,7 @@
6
6
  | [Cart Bundle Submission & the bundleUuid Identity Contract](features/cart-bundle-submission-and-identity.md) | How cart **bundles** (kits) are turned into `SalesOrderItems` when a cart is submitted or an existing order is edited, and the **identity-field contract** every | src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts, src/utils/formatSalesOrderBundlesFromApi.ts, src/stores/useCartStoreZu.ts, src/pages/OrderDetails/helpers/formatSalesOrderDataFromLocalStorage.ts, src/pages/OrderDetails/view/components/OrderItems.tsx |
7
7
  | [Cart Notification Emails — duplicate prevention](features/cart-notification-emails.md) | On the cart "Notifications" section a user can add CC email addresses to an order. | src/pages/Cart/CartPage.tsx, src/pages/Cart/view/cartForm/CartForm.tsx, src/stores/useEmailOptionsStore.ts, src/stores/useCartSalesQuoteZu.ts, src/pages/Cart/viewModel/FIELDS/*/*/*/CARTPAGE.ts |
8
8
  | [Cart Page — config-driven form architecture (current state + planned refactor)](features/cart-page-config-architecture.md) | The Cart page (`src/pages/Cart/`) is the most config-heavy page in `toga2-commerce`. | src/pages/Cart/CartPage.tsx, src/pages/Cart/view/cartForm/CartForm.tsx, src/pages/Cart/view/cartForm/CartFormSection.tsx, src/pages/Cart/view/cartForm/CartFormRenderer.tsx, src/pages/Cart/view/EditCart.tsx, src/pages/Cart/view/EditOrder.tsx, src/pages/Cart/viewModel/useEditOrderOrEditCartViewModel.ts, src/pages/Cart/viewModel/FIELDS/*/*/*/CARTPAGE.ts, src/hooks/useAssignClientFields.ts |
9
- | [Client Fields — per-tenant / language / role content & config](features/client-fields.md) | Almost no user-facing text, field layout, or page config is hard-coded in `toga2-commerce`. | src/fieldsConfig/index.ts, src/fieldsConfig/getClientLoginFields.ts, src/fieldsConfig/clientFields/COMPASS.json, src/fieldsConfig/clientFields/COMPASSCANADA.json, src/fieldsConfig/clientFields/QUAD.json, src/pages/Cart/api/CartApi.ts, src/hooks/useAuthenticationFlow.ts, src/contexts/AuthContext.tsx, src/pages/Login/viewModel/useLoginPageViewModel.ts, src/hooks/useAssignClientFields.ts, src/hooks/useDynamicConditionalFieldOptions.ts, src/stores/useFieldsStore.ts, src/components/BaseDetailField/BaseDetailField.tsx, src/components/NavIcons/NavIconItem.tsx, src/components/Submenus/AlertSubmenu.tsx, src/components/Submenus/types.ts, src/pages/Account/AccountPage.tsx, src/pages/Account/view/MyOrdersView.tsx, src/pages/GetSupport/GetSupportPage.tsx, src/pages/GetSupport/viewModel/useGetSupportViewModel.ts, src/queries/queries.ts |
9
+ | [Client Fields — per-tenant / language / role content & config](features/client-fields.md) | Almost no user-facing text, field layout, or page config is hard-coded in `toga2-commerce`. | src/pages/Account/view/MySettingsView.tsx, src/fieldsConfig/index.ts, src/fieldsConfig/getClientLoginFields.ts, src/fieldsConfig/clientFields/COMPASS.json, src/fieldsConfig/clientFields/COMPASSCANADA.json, src/fieldsConfig/clientFields/QUAD.json, src/pages/Cart/api/CartApi.ts, src/hooks/useAuthenticationFlow.ts, src/contexts/AuthContext.tsx, src/pages/Login/viewModel/useLoginPageViewModel.ts, src/hooks/useAssignClientFields.ts, src/hooks/useDynamicConditionalFieldOptions.ts, src/stores/useFieldsStore.ts, src/components/BaseDetailField/BaseDetailField.tsx, src/components/NavIcons/NavIconItem.tsx, src/components/Submenus/AlertSubmenu.tsx, src/components/Submenus/types.ts, src/pages/Account/AccountPage.tsx, src/pages/Account/view/MyOrdersView.tsx, src/pages/GetSupport/GetSupportPage.tsx, src/pages/GetSupport/viewModel/useGetSupportViewModel.ts, src/queries/queries.ts |
10
10
  | [Config-Driven Expedited Shipping Gating (Cart)](features/expedited-shipping-gating.md) | On the toga2-commerce **Cart** page, expedited shipping options (**"2nd Day EOB"** and **"Next Day Air"**) are only offered in the *Shipping Method* dropdown wh | toga2-commerce/src/pages/Cart/helpers/shippingOptionGates.ts, toga2-commerce/src/pages/Cart/viewModel/FIELDS/shared/shippingOptionGates.ts, toga2-commerce/src/pages/Cart/view/cartForm/CartForm.tsx, toga2-commerce/src/pages/Cart/CartPage.tsx |
11
11
  | [Multi-Tenant Resolution & Theming](features/multi-tenant-theming.md) | `toga2-commerce` serves multiple clients from one codebase. | src/themeConfig/themes.json, src/themeConfig/ThemeContext.tsx, src/themeConfig/types.ts, src/components/ThemeSwitcher/ThemeSwitcher.tsx, src/components/AuthLayout/AuthLayout.tsx, src/api/axiosInstance.ts, src/contexts/AuthContext.tsx, tailwind.config.js |
12
12
  | [AWS Amplify Build & Deploy (non-prod environments)](workflows/amplify-build-and-deploy.md) | How `toga2-commerce` (React + Vite, "commerce2-react") builds and deploys on **AWS Amplify**. | toga2-commerce/amplify.yml, toga2-commerce/.gitattributes, toga2-commerce/package.json, toga2-commerce/.github/workflows/sync-stage-environments.yml |
@@ -6,9 +6,10 @@ project: TOGa Commerce
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-22
10
- owners: ["apeterson", "tcox"]
9
+ updated: 2026-07-23
10
+ owners: ["apeterson", "tcox", "bala"]
11
11
  files:
12
+ - src/pages/Account/view/MySettingsView.tsx
12
13
  - src/fieldsConfig/index.ts
13
14
  - src/fieldsConfig/getClientLoginFields.ts
14
15
  - src/fieldsConfig/clientFields/COMPASS.json
@@ -292,9 +293,21 @@ via the [Persona Name Translation](../_underscore/features/persona-name-translat
292
293
  cached by React Query and does **not** refetch on a language switch by default. `src/queries/queries.ts`
293
294
  keeps a `LANGUAGE_SENSITIVE_QUERY_KEYS` allow-list of query keys to invalidate/refetch when the
294
295
  language changes. The persona query keys `userPersonaDataOnLogin` and `userPersonaDataOnViewAs` were
295
- added to it 2026-07-22 so translated persona names appear after a language switch without a full page
296
- reload. When you add a new API-driven string that must respond to the language switcher, add its query
297
- key here.
296
+ added to it 2026-07-22. When you add a new API-driven string that must respond to the language
297
+ switcher, add its query key here.
298
+
299
+ **Caveat — invalidation only refreshes values a live query actually owns.** This allow-list is
300
+ necessary but **not sufficient** for the persona *name*: the displayed name comes from
301
+ `user._personaOptions` in the **persisted Zustand `user` store** (localStorage), populated once at
302
+ login and **not** driven by a live React Query — so no key in `LANGUAGE_SENSITIVE_QUERY_KEYS` refreshes
303
+ it. Switching language must **explicitly refetch** the persona list:
304
+ `src/pages/Account/view/MySettingsView.tsx` `saveAllChanges` `case 'language'` now calls
305
+ `fetchUserPersona(user)` (with the post-`refreshAuthTokens` JWT) and reassigns `_personaOptions` via
306
+ `setUser`, in its own try/catch so a persona-refetch failure can't roll back the saved language. The
307
+ login-modal `LoginSettingsSwitcher.tsx` still has this gap. Before this fix the persona name stayed in
308
+ the old language until a full re-login. Takeaway: for translated data held in a **persisted store**
309
+ rather than a live query, a `LANGUAGE_SENSITIVE_QUERY_KEYS` entry does nothing — refetch it explicitly
310
+ on switch.
298
311
 
299
312
  ## Gotchas
300
313
 
@@ -326,6 +339,13 @@ key here.
326
339
  slip. Do not "reconcile" the two lists.
327
340
 
328
341
  ## Change history
342
+ - 2026-07-23 — **Correction:** clarified that `LANGUAGE_SENSITIVE_QUERY_KEYS` alone does **not**
343
+ refresh the persona name on language switch — `user._personaOptions` lives in the persisted Zustand
344
+ `user` store (populated once at login), not a live React Query, so nothing in the allow-list owns it.
345
+ `MySettingsView.tsx` `case 'language'` now explicitly refetches `fetchUserPersona(user)` with the
346
+ refreshed JWT and reassigns `_personaOptions` (own try/catch); the `LoginSettingsSwitcher` modal
347
+ still has the gap. General rule recorded: translated data in a persisted store must be refetched
348
+ explicitly on switch. (bala)
329
349
  - 2026-07-22 — **Correction:** the registry French key for COMPASSCANADA is **`fr-CA`, not `fr`**
330
350
  (verified `src/fieldsConfig/index.ts`); documented language persistence (zustand `persist` key
331
351
  `"fields-key"`, `setLanguage` stores the option uuid `en`/`fr-CA`) and the `startsWith("fr")` rule —
@@ -14,7 +14,7 @@ project: _Underscore
14
14
  client: compass-canada
15
15
  type: profile
16
16
  status: active
17
- updated: 2026-07-22
17
+ updated: 2026-07-23
18
18
  owners: [jcardinal, bala, tcox, apeterson]
19
19
  files: []
20
20
  related:
@@ -70,11 +70,14 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
70
70
  (16 rows seeded). French was extracted from the old bilingual `"English/French"` `Assortments.name`
71
71
  values, which were then cleaned to English-only. See
72
72
  [Assortment Name Translation](../../2.0/apps/_underscore/features/assortment-name-translation.md).
73
- - **Persona names (fr-CA) — DRAFTED / DEFERRED, not yet run.** A `PersonaTranslations` sidecar copying
74
- the assortment pattern (Core record 346, fr-CA seed for 33 personas) is fully drafted in working
75
- trees and `sql-reviewer`-approved but **not committed or run** — ids and 3 name-matched personas need
76
- live-DB verification first (RDS was VPC-unreachable). The storefront side (persona query keys made
77
- language-sensitive) already shipped. See
73
+ - **Persona names (fr-CA) — backend finalized, NOT yet run; French text needs business sign-off.** A
74
+ `PersonaTranslations` sidecar copying the assortment pattern (Core record **348** / RecordFields
75
+ 2469–2473, after `NotificationTranslations` 347; ids re-based against a confirmed prod snapshot) with
76
+ a fr-CA seed for all **34** Canada personas is fully built in working trees but **not deployed**. The
77
+ French persona names are **proposed** and still need Compass business sign-off (brand/sector proper
78
+ nouns: Eurest, Chartwells, ESFM, Foodbuy). The storefront side is fixed: `MySettingsView` now
79
+ explicitly refetches the persona list on language switch (the persisted-store `_personaOptions` isn't
80
+ refreshed by cache invalidation alone). See
78
81
  [Persona Name Translation](../../2.0/apps/_underscore/features/persona-name-translation.md).
79
82
  - **Storefront French localization** (labels, submenus, orders-for-others, Get Support illustration) is
80
83
  driven by the COMPASSCANADA FIELDS JSON; the registry language key is **`fr-CA`** (not `fr`). See
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.418",
3
+ "version": "1.0.419",
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",