toga-ai 1.0.876 → 1.0.878

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: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-21
10
- owners: [jcardinal]
9
+ updated: 2026-09-23
10
+ owners: [jcardinal, mhammontree]
11
11
  files:
12
12
  - _underscore/Model/Client/User.php
13
13
  - worker2/Worker/Sync/ActiveDirectory.php
@@ -53,15 +53,19 @@ The interceptor covers the **generic api2 CRUD** for everyone. The dedicated cre
53
53
  | ClickUp user cache | `worker2/Worker/Netsuite/Opportunity.php` `cacheClickupUser()` (raw SQL) | True | **fixed** — `ensureTrueContactId` |
54
54
  | Store SAML/hash SSO | `store/_/app/framework.php` | Prudential | **fixed** — see [prudential-sso-login](../../../1.0/apps/store/features/prudential-sso-login.md) |
55
55
  | SAML gateway | `_Model_Rate_ClientAuthentication` (saml) | Rate | already sets it |
56
- | Compass PeopleFile | worker2 (raw SQL) | Compass | already sets it |
56
+ | Compass PeopleFile | worker2 (raw SQL) | Compass | **fixed 2026-09-23** — it set `contactId` *after* the insert; see below |
57
57
 
58
58
  Northwell ServiceNow users arrive via api2 eBonding (`_Model_Northwell_Ticket` interceptors), not a dedicated worker, so the interceptor covers them.
59
59
 
60
60
  ## Gotchas
61
61
 
62
+ - **⚠ Create the Contact BEFORE the `Users` INSERT — a create-then-`UPDATE` path breaks the moment the column goes NOT NULL.** Omitting `contactId` from an INSERT column list makes MySQL write `0`, which the FK rejects (`Cannot add or update a child row ... CONSTRAINT Users_ibfk_1`). Compass's PEOPLE importer failed in prod this way on 2026-09-22, a full day after the tenant migration ran — audit every remaining raw-SQL user-insert path for the same ordering, not just for "does it set contactId eventually". Worked fix: [Compass PEOPLE-file user lifecycle](../../../clients/compass-usa/features/people-file-user-lifecycle.md).
63
+ - **⚠ Do not read the new Contact's id with `LAST_INSERT_ID()` through `_Query`.** `_Query` routes SELECTs to the read host and `LAST_INSERT_ID()` is per connection, so it can return `0` — which is now an FK violation rather than a harmless null. Use `mysqli_insert_id` on the write/transaction connection and reject an id `<= 0`. See [Per-client database connections](./per-client-database-connections.md).
64
+
62
65
  - **⚠ A reconstructed `new _Model_Client_User($id)` does NOT carry `contactId`.** In `ActiveDirectory.php` the AD sync reads the existing `contactId` from the **bulk SELECT** and only creates a contact when it is truly missing. A naive `empty($user->contactId)` guard against a freshly reconstructed model reads null even when the row has a contact — and would **mint a duplicate contact every sync**. Trust the SELECT'd value, not the rehydrated model property.
63
66
  - **⚠ Set `primaryContactEmailAddressId` with a second server-side load+save, never a nested payload.** The nested singular-FK write re-parents (CREATE) or echoes null (UPDATE).
64
67
  - **Interceptor no-ops on a user with no email** — a no-email straggler gets no Contact from this path (the backfill migrations handle those name-only, per client). It is why the fan-out `NOT NULL` skips a client that still has a no-email residual (see [rerunnable-additive-inserts](../../dbchanges2/features/rerunnable-additive-inserts.md)).
65
68
 
66
69
  ## Change history
70
+ - 2026-09-23 — Corrected the origin map: the Compass PEOPLE importer did **not** safely set `contactId` — it inserted `Users` first and linked afterwards, so the NOT NULL rollout broke it in prod (TRUE-82230). Added the write-order and `LAST_INSERT_ID()` gotchas for every raw-SQL creation path. (mhammontree)
67
71
  - 2026-09-21 — Initial. Decided `Client_*.Users.contactId` goes NOT NULL platform-wide (CTO Option B); built the shared `_Model_Client_User::postPost` interceptor (Core row: recordId 2, clientId+apiId NULL) to create+link a Contact for api2-created users; fixed the worker2 AD-sync and `cacheClickupUser` True paths; recorded the origin map and the reconstructed-model `contactId` gotcha. (jcardinal)
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-09-23
10
10
  owners: ["dfranks", "jcardinal", "mhammontree", "apeterson", "kyalamarthi", "bala"]
11
11
  files:
12
12
  - _underscore/Database.php
@@ -59,6 +59,9 @@ When `_underscore` serves a request for a client it opens **three distinct per-c
59
59
 
60
60
  ## Gotchas
61
61
 
62
+ - **⚠ NEVER read `LAST_INSERT_ID()` (or any other per-connection session value) through `_Query`.** `_Query` defaults `_isReadHostEnabled = true` (`Query.php:8`) and routes SELECTs to `CONNECTION_READ` (`Query.php:273-276`). `LAST_INSERT_ID()` is scoped to the connection that did the INSERT, so a read-host SELECT returns **0** — silently wrong, and now an FK violation wherever the id is used as a foreign key. Use `mysqli_insert_id` on the write/transaction connection and reject `<= 0`. It is not biting today only because most client registrations leave `writeHost`/`readHost` null, and `Database.php:488` then points READ and WRITE at the **same** `mysqli` handle (so raw `mysqli_query` and `new _Query(...)` share one connection and one transaction). Configuring a real read host for any client would break every such call at once. Worked example: [Compass PEOPLE-file user lifecycle](../../../clients/compass-usa/features/people-file-user-lifecycle.md).
63
+ - **Raw `mysqli_query` bypasses `_Query`'s automatic query-cache reset.** After a hand-rolled write, call `_Database::resetQueryCache($dbName)` yourself or later model reads serve stale rows.
64
+
62
65
  - **NEVER build a client's database name as `'Client_' . $clientIdentifier`.** They diverge in production (`Compass_Usa` → `Client_Compass`). Resolve `Core.Clients.clientDatabaseId` → `Core.Databases.name`. This mirrors the log-database resolution already done in `_underscore`'s `Email.php`. A string-built name fails only for the clients whose names happen to diverge, so it passes local testing and breaks in production.
63
66
  - **The same rule applies to the LOG database: never build `'Logs_' . $clientIdentifier`.** Resolve `Core.Clients.logDatabaseId` → `Core.Databases.name` (`Compass_Usa` → `Logs_Compass`). A string-built log-DB name silently targets a schema that does not exist.
64
67
  - **`_Database::registerClientDatabases()` is unsafe for a loop over ALL clients.** It INNER JOINs the client database *and* archive database alongside the log database, so a client missing either row drops out of the result set entirely — and the method then dereferences the null row (`$database->clientId`) and throws. It also opens three connections per client when a caller may need only one. For an all-client fan-out, run a **log-database-only** variant with **LEFT OUTER JOINs** and report clients that resolve to nothing, rather than letting them vanish. Worked example: [All-Client Email Queue Monitor](../../worker2/features/all-client-email-queue-monitor.md).
@@ -86,3 +89,6 @@ Methodology lesson — why the wrong conclusion was reached: the model-class com
86
89
  - [Running a 2.0 app locally (browser end-to-end)](../workflows/running-a-2.0-app-locally.md) — the full local-stack runbook these three connections are one requirement of.
87
90
  - [NetSuite → TOGa Supply per-client sync](../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md) — the work that surfaced this.
88
91
  - [Re-pointing a DB alias mid-request](./database-alias-repointing.md)
92
+
93
+ ## Change history
94
+ - 2026-09-23 — Added the read-host routing trap: `_Query` sends SELECTs to `CONNECTION_READ`, so `LAST_INSERT_ID()` read through `_Query` can return 0; noted that null `writeHost`/`readHost` currently collapses both to one handle, and that raw `mysqli_query` needs a manual `resetQueryCache()`. (mhammontree)
@@ -6,11 +6,12 @@ project: Worker
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: ["kyalamarthi", "bala"]
9
+ updated: 2026-09-23
10
+ owners: ["kyalamarthi", "bala", "mhammontree"]
11
11
  files:
12
12
  - worker2/index.php
13
13
  - worker2/_.php
14
+ - _underscore/Email.php
14
15
  - library/_.php
15
16
  - test/@dave/nsq2.php
16
17
  - worker2/composer.json
@@ -128,6 +129,10 @@ NetSuite or to `Forecast`.
128
129
 
129
130
  ## Gotchas
130
131
 
132
+ - **⚠ Locally, worker2's `Core` resolves to the 1.0 `Core` database, not the 2.0 one.** `worker2/_.php:20` has `const DB_CORE = 'Core';` and `Core` is never explicitly registered, so the schema name is used literally. On a dev machine the 2.0 tables live in **`Core_2`** (48 tables) while `Core` is the 1.0 schema (10 tables) — symptom: `Table 'core.workerjobs' doesn't exist`. Workaround: flip `DB_CORE` to `'Core_2'` locally and **revert before committing**.
133
+ - **That workaround is incomplete — `_underscore/Email.php:435` hardcodes the literal prefix `FROM Core.Clients`.** So the summary-email step still throws `Table 'core.clients' doesn't exist` no matter what `DB_CORE` says. In the Compass PEOPLE cron that step runs **last**, after both tenants commit, so the data work is unaffected — but do not read the trailing exception as a failed run.
134
+ - **⚠ A stale local client database silently invalidates the test.** A local tenant that predates a migration can make broken code **pass** locally — e.g. local `Client_Compass` still had `contactId` NULLABLE, so a missing FK value was accepted where prod's `NOT NULL` turns it into `0` and a constraint error. Before testing anything schema-sensitive, drop and reload the tenant from production and **re-verify the constraint/index actually survived the copy**.
135
+
131
136
  - **The ad-hoc SuiteQL runner `test/@dave/Junk Drawer/NetSuite/nsq.php` cannot start — stale bootstrap.**
132
137
  Its `require` walks three `../` from its current folder, which resolves to a **nonexistent
133
138
  `test/worker`** (the file was moved after it was written), so it dies before any query. Replaced by
@@ -174,3 +179,6 @@ NetSuite or to `Forecast`.
174
179
  - [Repairing non-prod metadata drift](../../dbchanges2/workflows/nonprod-metadata-drift-repair.md).
175
180
  - [`ENVIRONMENT` decides the `_underscore` branch and Config file](../../api2/features/environment-variable-drives-underscore-branch.md)
176
181
  — the api2 sibling of steps 2–3.
182
+
183
+ ## Change history
184
+ - 2026-09-23 — Added three local-run traps found on TRUE-82230: worker2's literal `DB_CORE = 'Core'` hits the 1.0 schema locally (2.0 lives in `Core_2`); `_underscore/Email.php:435` hardcodes `FROM Core.Clients` so the summary-email step throws regardless; and a stale local client DB can make broken code pass (reload from prod and re-verify constraints). (mhammontree)
@@ -17,8 +17,8 @@ project: _Underscore
17
17
  client: compass-canada
18
18
  type: profile
19
19
  status: active
20
- updated: 2026-09-21
21
- owners: [jcardinal, bala, tcox, apeterson, ajean]
20
+ updated: 2026-09-23
21
+ owners: [jcardinal, bala, tcox, apeterson, ajean, mhammontree]
22
22
  files: []
23
23
  related:
24
24
  - ../compass-usa/profile.md
@@ -29,6 +29,7 @@ related:
29
29
  - ../compass-usa/features/approval-decision-flow.md
30
30
  - ../compass-usa/features/stranded-approval-reassignment.md
31
31
  - ../compass-usa/features/people-file-user-lifecycle.md
32
+ - ../compass-usa/features/persona-model-and-levy-gating.md
32
33
  - ../compass-usa/features/isfulfillable-data-quality-and-type-rule.md
33
34
  - ../compass-usa/features/mits-sales-order-transmission-alerting.md
34
35
  - features/french-order-email-localization.md
@@ -6,12 +6,15 @@ project: Worker
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: [bala, ajean]
9
+ updated: 2026-09-23
10
+ owners: [bala, ajean, mhammontree]
11
11
  files:
12
12
  - worker2/Worker/Client/Compass/PeopleFile.php
13
13
  related:
14
14
  - ./persona-model-and-levy-gating.md
15
+ - ../../../2.0/apps/_underscore/features/client-user-contactid-required.md
16
+ - ../../../2.0/apps/_underscore/features/per-client-database-connections.md
17
+ - ../../../2.0/apps/worker2/workflows/running-worker2-locally.md
15
18
  - ../../../1.0/apps/tools/features/compass-user-persona-admin.md
16
19
  - ./approval-decision-flow.md
17
20
  - ./stranded-approval-reassignment.md
@@ -50,6 +53,16 @@ where `5` is `CONTACT_UNLINK_GRACE_DAYS`. The window exists for the `contactId`-
50
53
 
51
54
  **Username casing is NOT a contributing cause.** Both the lookup build and the post-insert refresh already `strtolower()` the `c_hrEmpUsername` key, so a casing change in the HR file cannot produce a miss. Do not look there.
52
55
 
56
+ ### Contacts are created BEFORE the Users insert (FK order, fixed 2026-09-23)
57
+ `Client_Compass.Users.contactId` and `Client_CompassCanada.Users.contactId` are **NOT NULL with FK `Users_ibfk_1` → `Contacts(id)`** (added 2026-09-22; platform rule: [Client Users require a contactId](../../../2.0/apps/_underscore/features/client-user-contactid-required.md)). The importer used to insert `Users` first *without* `contactId` in the column list (MySQL wrote `0`), then create the Contact and link it with an `UPDATE`. Under the FK that INSERT fails outright.
58
+
59
+ Current order for a **new** user:
60
+ 1. `insertContactsForNewUsers()` — creates the Contact for every new user and stamps the id onto the staged row. It **throws** if a staged user has no contact rather than letting a `0` through.
61
+ 2. The `Users` batch INSERT, with `contactId` now in the column list.
62
+ 3. `executeContactsInserts()` Step 2's `UPDATE` covers **existing** users only.
63
+
64
+ A `_isNewUser` flag on each staged contact is what separates the two paths; existing users missing a contact keep the old create-then-`UPDATE` route. `insertContact()` is the shared helper both use.
65
+
53
66
  ### Order of operations inside a single run
54
67
  Within one job the **inserts (and their id refresh) run before the inactivation batch**. That ordering is the hook point for anything that has to bridge an old row to a new one: at insert-refresh time the **new row already has its id** and the **old row is still findable**. A deactivation-time hook would be worse — by then the new row's relationship to the old one has to be rediscovered, and the deactivation path has no model hooks at all.
55
68
 
@@ -83,7 +96,7 @@ The insert-time hook designed 2026-08-12 was built and grew into a reusable engi
83
96
  - **A duplicate account strands approvals, but not email.** The approval gate matches on user id, so a decision assigned to the old row is invisible to the same human's new account — while the notification email still arrives, because both rows carry the same address. Visible symptom: a person chased to approve an order they cannot see. Detail: [Approval-Decision Flow](./approval-decision-flow.md).
84
97
  - **Widening the lookup window is not a free fix.** Removing or extending the grace window would stop new duplicates at source, but also changes which rows the `contactId`-unlinking logic considers and would begin reactivating long-dead accounts. Deliberate deferred item, not an oversight.
85
98
  - **`PeopleFile.php` contains a literal SFTP credential in class constants.** Do not paste class constants from this file into tickets, docs, or chat transcripts.
86
- - **⚠ `DRY_RUN` does NOT protect the source file — a "safe" dry run still deletes the PEOPLE file.** The SFTP delete at the end of `ProcessPeopleFiles()` is gated on **`TEST_MODE` only**, not on `DRY_RUN`. Running with `DRY_RUN = true` still removes the PEOPLE file from `sftp.goagilant.com` and destroys the input you were inspecting. Take your own copy first, or set `TEST_MODE`.
99
+ - **⚠ `TEST_MODE` and `DRY_RUN` do different jobs and NEITHER alone is safe — set BOTH to test.** `TEST_MODE` reads a local file instead of SFTP and skips the SFTP delete, but does **not** stop a single database write. `DRY_RUN` skips the DB writes but does **not** stop the SFTP delete — a "safe" dry run still removes the PEOPLE file from `sftp.goagilant.com` and destroys the input you were inspecting. `DRY_RUN` also skips the whole contact/user insert path (it sits inside `if (!self::DRY_RUN)`), so a dry run **cannot** test that code and reports a misleading success. `TEST_FILE_PATH`'s committed value (`//var//www//html//...`) is not where the app lives on Elastic Beanstalk (`/var/app/current/...`).
87
100
  - **⚠ Read newly inserted user ids with `MAX(id)`, on the transaction connection.** A returning employee usually keeps the **same username** as their dead row, so a plain username lookup after the insert can return the **dead** id. And the new rows are invisible to any other connection until the tenant transaction commits, so the read has to happen on the transaction connection.
88
101
  - **`_Model_Client_Logs_Record::addNote()` is a no-op in this cron.** Staged notes are only flushed by api2, so a worker must write its `Record` rows itself. See [Record Change Audit Log](../../../2.0/apps/_underscore/features/record-change-audit-log.md).
89
102
  - **`Client_Compass.Users` is ~176,000 rows with no index on `c_hrEmpPersonnelNbr`, `email`, `c_hrEmpUsername`, or `isActive`.** Any per-row lookup against those columns is a full scan — `EXPLAIN` returns `type=ALL`, 176,018 rows, no possible keys (re-confirmed prod 2026-09-02, same on `Client_CompassCanada`). An index migration is recommended and **still not done**. **The blank-client template is not a safe proxy for this schema**: `dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql` declares `UNIQUE KEY email (email)`, and Compass has diverged from it. Check the live tenant, never the template.
@@ -95,6 +108,11 @@ The insert-time hook designed 2026-08-12 was built and grew into a reusable engi
95
108
  - **⚠ The Tools admin's edit block covers only the original four addresses, not the two CA-only ones.** The protected set grew to six in worker2 on 2026-09-16; the `tools` block list was not changed and was not re-checked. Until it is, someone can edit the email of a CA-protected user in [Tools Compass admin](../../../1.0/apps/tools/features/compass-user-persona-admin.md) and silently drop them out of the protected set — because the match is on email, not id.
96
109
  - **⚠ Adding a new "always keep this person active" rule means touching exactly two places.** `executeForTenant()`'s inactivation loop is the only `isActive = 0` writer, and `ensureCrossRegionUsersActive()` is the post-run sweep. Guard both and the requirement is covered; guard only the loop and the post-run sweep still decides the final state; guard only the sweep and the user flickers inactive between the two steps.
97
110
 
111
+ - **⚠ A whole tenant run is ONE transaction, and US runs first — so a US exception means Canada never executes at all.** Any throw rolls back everything for that run: inserts, updates, reactivations, **deactivations**, personas, roles and approval reassignment. The 2026-09-22 FK failure blocked both tenants for a day this way. Rollback is clean (proven: a run that died on `Users_Personas` after the `Users` insert left 0 rows behind), and the exception aborts **before** the SFTP delete, so the input file survives and the next run reprocesses it.
112
+ - **⚠ Read `LAST_INSERT_ID()` on the write connection, never through `_Query`.** `_Query` routes SELECTs to the read host, and `LAST_INSERT_ID()` is per connection — so it would return `0`, which the `contactId` FK now rejects. `insertContact()` uses `mysqli_query` + `mysqli_insert_id` on the transaction connection, throws if the id is `<= 0`, and calls `_Database::resetQueryCache($dbName)` by hand because it bypasses `_Query`'s automatic cache reset. Framework detail: [Per-client database connections](../../../2.0/apps/_underscore/features/per-client-database-connections.md).
113
+ - **A "successful" run is not proof a file was processed.** The SFTP picker is FIFO (oldest first) and handles **one** file per run, and an **empty SFTP directory is a silent success** — the job just returns. The PEOPLE file is a full snapshot of the active roster (80,805 usernames vs 75,133 active users on 2026-09-23), not a delta.
114
+ - **⚠ A stale local client DB silently invalidates a test of this cron.** Local `Client_Compass` had `contactId` NULLABLE, so the *old broken code passed locally* — a NULL FK is allowed; only prod's NOT NULL turns the missing column into a `0`. Local also lacked persona 35 and still called persona 1 "All". Drop and reload both tenants from prod, then re-verify the constraint survived the copy, before trusting any result. Local-run mechanics: [Running worker2 locally](../../../2.0/apps/worker2/workflows/running-worker2-locally.md).
115
+
98
116
  ## Related
99
117
  - [Persona Model & Levy-Sector Gating](./persona-model-and-levy-gating.md) — the persona/catalogue half of the same cron.
100
118
  - [Compass Approval-Decision Flow](./approval-decision-flow.md) — what a duplicate account breaks downstream.
@@ -102,3 +120,6 @@ The insert-time hook designed 2026-08-12 was built and grew into a reusable engi
102
120
  - [Record Change Audit Log](../../../2.0/apps/_underscore/features/record-change-audit-log.md) — why the importer's raw writes leave no audit trail.
103
121
  - [Compass USA profile](../profile.md)
104
122
  - [Users.email as login identity](../../../2.0/apps/_underscore/features/user-email-as-identity-and-password-reset.md)
123
+
124
+ ## Change history
125
+ - 2026-09-23 — **Fixed the `Users.contactId` FK write order (TRUE-82230).** Jeff's `NOT NULL` + `Users_ibfk_1` on both tenants broke the importer from 2026-09-22 09:15 (last good run 2026-09-21); the whole run rolled back and Canada never executed. New `insertContactsForNewUsers()` creates the Contact before the `Users` batch and puts `contactId` in the INSERT column list; existing users missing a contact keep the create-then-UPDATE path. Also recorded: `LAST_INSERT_ID()` must be read on the write connection (not `_Query`); `TEST_MODE` vs `DRY_RUN` neither being safe alone; the FIFO/one-file/empty-is-silent-success facts; and that a stale local DB hides this class of bug. Verified against prod-reloaded local schemas: 80 new US / 17 new CA users, 0 with null-or-0 `contactId`. (mhammontree)
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-17
10
- owners: [bala, ajean]
9
+ updated: 2026-09-23
10
+ owners: [bala, ajean, mhammontree]
11
11
  files:
12
12
  - worker2/Worker/Client/Compass/PeopleFile.php
13
13
  related:
@@ -130,7 +130,21 @@ persona 1 *removed*, non-Levy users have it *added*; there is no Non-Levy person
130
130
  confirmed **unreachable via the Levy path**: `Client_CompassCanada` has **zero** locations with
131
131
  `c_erpEntityId = 'K00000'` and **zero** users with either hardcoded Levy personnel number.
132
132
 
133
- VIP (35) and CDL (30) handling is orthogonal and applies to both tenants unchanged.
133
+ **⚠ Persona ids mean DIFFERENT things per tenant — and the VIP/CDL branches are gated inconsistently.**
134
+
135
+ | id | `Client_Compass` (US) | `Client_CompassCanada` (CA) |
136
+ |---|---|---|
137
+ | 1 | Base | All |
138
+ | 24 | Levy Purchaser | ESFM - CGC |
139
+ | 30 | Macbook and Surface (CDL) | Foodbuy Member Development |
140
+ | 35 | VIP Role | **does not exist** |
141
+ | 40 | Non-Levy | — |
142
+
143
+ The constants are named `PERSONA_ID_US_*`, so US-only was the intent, but only one branch enforces it:
144
+ - **Persona 30 is gated** (`$shouldHavePersona30 = $tenantKey === 'US' && ...`). Side effect: for CA the flag is always false, so the `elseif (!$shouldHavePersona30 && $hasPersona30)` delete branch **strips persona 30 from every Canadian user who holds it** — and 30 is a real CA persona. Harmless only because prod `Client_CompassCanada` currently has **0** users on it.
145
+ - **Persona 35 is NOT gated** (`if ($c_isVip)` with no tenant check). A Canadian VIP would hit an FK error on `Users_Personas` for a nonexistent persona and roll back the entire Canada tenant. It has not fired because `$c_isVip` is read from the **file**, not the DB, and the file carries 0 CA VIP rows (2,244 US; `erp_system_id` 2010 = CA, 1001 = US, VIP is column 19) — even though one CA user has `c_isVip = 1` in the `Users` table.
146
+
147
+ Both are **latent, not live, and not yet fixed** — each needs its own ticket. **Deploy risk:** US used to throw first so Canada never ran; once Canada executes again these branches are reachable.
134
148
 
135
149
  ## Gotchas
136
150
 
@@ -168,6 +182,7 @@ VIP (35) and CDL (30) handling is orthogonal and applies to both tenants unchang
168
182
  constants from this file into tickets, docs, or chat transcripts.
169
183
 
170
184
  ## Change history
185
+ - 2026-09-23 — Recorded the per-tenant persona-id table and two **unfixed** CA gating gaps found while fixing TRUE-82230: persona **35 (VIP) has no tenant check** and would FK-fail on Canada (latent only because the file carries no CA VIP rows), and the persona **30 delete branch strips a real CA persona** from every Canadian user holding it (0 today). Both need their own tickets; both become reachable now that Canada runs again. (mhammontree)
171
186
  - 2026-09-17 — **The refactor is live in prod (TRUE-80672) and the persona set changed shape.** It is
172
187
  a **three-way** split: Base (1) keeps the 9 kits Levy also held, **Non-Levy is id 40 / number 42**
173
188
  (id and number differ — never assume they match), and **Levy (24) now holds only its own 5 kits**.
@@ -0,0 +1,90 @@
1
+ ---
2
+ type: session
3
+ slug: bundles-page-bugs
4
+ title: TRUE-82138 — Bundles page view/edit/create bugs
5
+ author: apeterson
6
+ repos: [toga25-supply, toga2-supply, _underscore, api2]
7
+ framework: "2.0"
8
+ client: compass-usa
9
+ status: active
10
+ created: 2026-09-23
11
+ updated: 2026-09-23
12
+ ---
13
+
14
+ # Session: bundles-page-bugs
15
+ **Date:** 2026-09-23
16
+ **Project/Repo:** toga25-supply (2.0), branch `TRUE-82138`
17
+ **Task:** Fix three reported bugs on the toga25-supply Bundles page for Compass USA / Compass Canada — description not editable on an inactive bundle, the "< Bundle" back button not working, and an edited bundle/kit price not showing in Supply (shows correctly in Commerce).
18
+
19
+ ---
20
+
21
+ ## What WORKED
22
+
23
+ - **Bug 1 root cause found and fixed.** blox `BaseInput.js` switches on `case "textArea"` and ends `default: return null`; the label is rendered OUTSIDE the switch. The bundle/vendor-item field JSONs said `"textarea"` (lower-case `a`), so the field rendered a label with no input. Evidence: live DOM on bundle 170 showed `<div class="_inputAndLabelWrapper_"><label for="description">Description</label></div>` with no `_inputContainer_`, while `Name` had both. Further evidence: a temporary `console.log` in `BundleRecordEdit.renderField` printed `DBG renderField description "textarea"` vs `DBG renderField name "text"`. Fixed in 6 files (`"textarea"` → `"textArea"`). **The bug has nothing to do with the bundle being inactive** — that is just how it was reported.
24
+ - **Bug 2 root cause found and fixed.** `BundleRecordModalLayout.tsx` passed `onBack={bundleItemDiscard.handleClose}`. `useDiscardCancelGuardrail.handleClose` only RETURNS a boolean — it never calls the `onClose` (`closeBundleItem`) it was given. The modal's X works because `TableRecordModal.onRequestClose` acts on the return value; the back button has no shell, so a clean form did nothing. Fixed with a new `handleBundleItemBack` that clears the `bundle-items` param when the guardrail returns true.
25
+ - **Bug 3 root-caused (not fixed).** Not a caching bug. `useBundleItemEditForm.tsx:75-77` PUTs `bundleItemPrices: [{ priceType: {uuid}, price }]` with **no `uuid` on the price row**, so api2 creates a new `BundleItemPrices` row on every save (per the nested-relationship-writes rule: no identifier → create). Supply then reads `bundleItemPrices?.[0]?.price` — the oldest row. Commerce reads the current one.
26
+ - **Bug 3 measured on production `Client_Compass`** (read-only SELECTs via `reader1.client.database.togahub.com`):
27
+ - **5,123 `BundleItemPrices` rows for 1,575 bundle items** (~3.3 each).
28
+ - Bundle item **266**: 15 rows, all `priceTypeId` 3, all `8.00`.
29
+ - Bundle item **434**: 7 rows — id 1005 (`priceType` 3, 554.35) … id 5422 (`priceType` 1, **533.34**, the real current price). Supply shows the oldest.
30
+ - `Logs_Compass.Api` `PUT /v2/bundle-items/%` by month starts at **2025-09 (2448 calls)** — 7 months before toga2-supply's Bundles page shipped, so a third, unidentified writer exists.
31
+ - **Same bug confirmed in `toga2-supply`** — `src/pages/Bundles/view/modals/viewEdit/BundleItemModal.tsx:151` builds the identical payload and line 106 reads `[0]`. First commit `d95d0520e`, 2026-04-07. toga25-supply inherited it 2026-06-25 (`a788081`).
32
+ - **Add Bundle Item modal layout fixed.** `.colRight` used `justify-content: space-between` across the full 1026px body, pushing two narrow columns to opposite ends. Changed to `flex-start`, and added a `.colSplit` modifier (57% / 43%, 5rem gap) applied only by `BundleItemRecordEdit.tsx`. Measured live: 539px / 407px, gap 80px.
33
+ - `npx tsc --noEmit -p tsconfig.app.json` exits 0 after every change.
34
+ - Two commits pushed: `9c4cfe9` (bugs 1+2), `c5f92d8` (modal layout).
35
+
36
+ ## What did NOT work — DO NOT RETRY THESE
37
+
38
+ - **Do NOT re-verify the bug-1 fix by reloading the dev page — the browser will still show the OLD behaviour.** After changing the JSON, the running page kept rendering `"textarea"`. Everything below was tried and did NOT clear it:
39
+ - `location.reload()` and `navigate_page` to the same URL — still stale.
40
+ - Restarting Vite with `--force` (twice, ports 5175 and 5180) — still stale.
41
+ - `fetch(url, {cache:'reload'})` on `/src/fieldsConfig/index.ts` and the JSON module, then `location.reload()` — still stale.
42
+ - A fresh page with a cache-busting `?cb=2` document query — still stale.
43
+ - Temporarily setting `inputType` to `"text"` to isolate — the description field STILL did not render, proving the page was not reading the changed file at all.
44
+ - Meanwhile `fetch` of the exact module URL the app imports returned `"inputType":"textArea"` (correct), and a `.tsx` edit in the same file tree DID hot-reload. So the server and the build are fine; only the JSON module is stuck in the page.
45
+ - **Do NOT restart Vite on a different port to get a clean cache.** Port 5180 is a different origin, so `localStorage` is empty and the app lands on `/login` with no session.
46
+ - **Ruled out as causes of the stale JSON (do not re-investigate):** wrong file (single import at `src/fieldsConfig/index.ts:38` → the DEFAULT JSON; `DEFAULT_BUNDLE_RECORD_EDIT_FIELDS` at line 121-122 is just `.DEFAULT` off that import; no client override), wrong server (PID on :5175 has cwd `/Users/alexpeterson/Agilant/toga25-supply`), stale Vite deps, and a stale `.tsx`.
47
+ - **Do NOT treat duplicate `BundleItemPrices` rows as intended price history.** This was proposed and checked against the docs, and the evidence is against it: `SalesOrderItems` carries its own `price`/`priceTypeId` snapshot; `_Model_Client_BundleItemPrice` has **no** `dtCreated`/`dtUpdated`/effective date/`isActive`, so nothing marks which row is current; and `clients/compass-usa/workflows/changing-a-kit-line-item.md` calls the duplicates a hazard ("do not blindly reproduce the duplicates either"). The real history mechanism is at the `BundleItems` level — mirror the row, set the old one `isActive = 0`, never delete, because `SalesOrderItems.bundleItemId` points at historical lines.
48
+ - **Do NOT blanket-dedupe to one row per bundle item.** Multiple rows per bundle item ARE legitimate — one per `(priceType, currency, plan)`. Item 434 genuinely has a `priceType` 3 row and a `priceType` 1 row. Only repeats WITHIN one combination are junk.
49
+
50
+ ## Not tried yet (candidates for next session)
51
+
52
+ - Identify the third writer behind the 2025-09 `PUT /v2/bundle-items/` traffic — read `requestPayload` on an early `Logs_Compass.Api` row; it names the app. This decides whether the fix belongs in each frontend or platform-side (a `MATCH_UPSERT` child policy on `BundleItems.bundleItemPrices`).
53
+ - Fix the bug-3 WRITE: send the existing price row's `uuid`, or PUT `/bundle-item-prices/{uuid}` directly. Direct is safer — a nested child failure on a parent PUT still returns 200. Needed in BOTH `toga25-supply` and `toga2-supply`.
54
+ - Fix the bug-3 READ: the frontends use `bundleItemPrices?.[0]` and ignore price type entirely, so even with clean data they can show the lease price where the purchase price belongs. Should select by the bundle item's `defaultPriceType`. Three sites: `BundleItemsTable.tsx:27`, `BundleItemRecordView.tsx:141`, `useBundleItemEditForm.tsx:18`.
55
+ - Write the `dbchanges2` cleanup migration for the duplicate prod rows (keep newest per price-type group). **Hand to the developer to run — never execute.**
56
+ - Add a unique key on `BundleItemPrices` so this cannot come back. Needs the key-shape answer first (see Blockers).
57
+ - Regression tests. The pre-commit check flagged `BundleRecordModalLayout` and `BundleItemRecordEdit` as untested. The back-button fix is easy to cover with a Vitest case.
58
+ - Apply the same 57/43 `.colSplit` to the read-only `BundleItemRecordView` if wanted — it still uses plain `.colRight`.
59
+
60
+ ## Current file state
61
+
62
+ | File | Status | Notes |
63
+ |------|--------|-------|
64
+ | `src/layout/BundleRecordModalLayout/BundleRecordModalLayout.tsx` | committed `9c4cfe9` | New `handleBundleItemBack`; `onBack` now wired to it |
65
+ | `src/layout/BundleRecordModalLayout/viewModel/FIELDS/DEFAULT/bundleRecordEditFields.json` | committed `9c4cfe9` | `"textarea"` → `"textArea"` (line 48) |
66
+ | `src/layout/BundleRecordModalLayout/viewModel/FIELDS/DEFAULT/bundleRecordViewFields.json` | committed `9c4cfe9` | same casing fix (line 75) |
67
+ | `src/layout/VendorItemRecordModalLayout/viewModel/FIELDS/{DEFAULT,COMPASS,COMPASSCANADA,NYCHH}/vendorItemRecordViewFields.json` | committed `9c4cfe9` | same casing fix, 4 files |
68
+ | `src/layout/BundleRecordModalLayout/BundleRecordModal.module.css` | committed `c5f92d8` | `.colRight` → `justify-content: flex-start`; new `.colSplit` (5rem gap, `flex: 57/43 1 0`) |
69
+ | `src/layout/BundleItemRecordModalLayout/components/BundleItemRecordEdit.tsx` | committed `c5f92d8` | applies `colSplit` alongside `colRight` |
70
+ | `.env.development` | **modified, NOT mine, left unstaged** | Working tree has the sandbox `VITE_API` line active; the committed version has it commented. Do not stage it. |
71
+ | `src/layout/BundleItemRecordModalLayout/hooks/useBundleItemEditForm.tsx` | **unchanged — bug 3 not started** | Line 75-77 is the create-instead-of-update write |
72
+
73
+ ## Decisions made
74
+
75
+ - **`.colSplit` as a scoped modifier, not a change to `.colRight`.** `.colRight` is shared with the bundle record modal (Overview / Restrictions). Forcing 57/43 there would be wrong. Only the `flex-start` change reaches it, which removes the same gutter without imposing a ratio. Rejected: changing `.colRight` outright.
76
+ - **`flex: 57 1 0` / `flex: 43 1 0` rather than percentages or a grid.** `flex-basis: 0` makes the ratio exact regardless of content width, and the flex numbers read as the percentages, so retuning is a one-number edit. Rejected: `width: 57%` (breaks with the gap), CSS grid (more churn for no gain).
77
+ - **Bug 3 fix deferred pending the unique-key shape.** It touches 13 months of prod data across three apps, so the key decision comes before any code. Rejected: patching toga25-supply alone, which would leave toga2-supply still writing duplicates.
78
+ - **Prefer a direct `PUT /bundle-item-prices/{uuid}` over a nested child write.** api2's update path returns 200 even when a nested CHILD write failed, so a nested price write gives no signal about whether it landed.
79
+
80
+ ## Blockers
81
+
82
+ - **Bug 3 is blocked on one answer from the developer:** should `BundleItemPrices` be unique on `(bundleItemId, priceTypeId)`, or also include `currencyId` and `planId`? Compass has both NULL, but Quad's per-currency pricing needs the wider key. This decides both the write fix and whether a real unique constraint can be added.
83
+ - **Bug 1's fix is not visually confirmed.** The mechanism is proven by DOM + runtime log, but the dev page never picked up the changed JSON (see the DO-NOT-RETRY list). Someone needs to confirm the textarea renders before the PR merges.
84
+
85
+ ## Exact next step
86
+
87
+ > Open the Bundles page in the app with DevTools "Disable cache" ticked and hard-refresh (Cmd+Shift+R), then open bundle 170 → Edit and confirm the **Description textarea now renders**. If it does, bug 1 is done and the only thing left is the developer's answer on the `BundleItemPrices` unique-key shape before starting bug 3.
88
+
89
+ ---
90
+ _Saved by /session-save on 2026-09-23_
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.876",
3
+ "version": "1.0.878",
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",