toga-ai 1.0.877 → 1.0.879

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,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-09-22
9
+ updated: 2026-09-23
10
10
  owners: ["jcardinal", "rgirish", "mhammontree", "ajean"]
11
11
  files:
12
12
  - _underscore/_underscore.php
@@ -371,6 +371,7 @@ multi-file UI components (`.php`/`.html`/`.css`/`.js`) invoked as `<_ComponentNa
371
371
 
372
372
  ## Gotchas
373
373
 
374
+ - **⚠ `_Environment::isDebugMode()` (Environment.php) returns TRUE when the `[_underscore]` config group is missing** (fail-safe to debug). Anything gated on it silently turns off in a prod config lacking the group — e.g. worker2 NetSuite order sends return `skippedDebugMode` and still record success. Prod must set `[_underscore]` with debug off. See [NetSuite sales-order push](../worker2/features/netsuite-salesorder-outbound-push.md).
374
375
  - **`FIELD_STORAGE` writes were silently dropped platform-wide (fixed 2026-07, `Model.php`).** Two
375
376
  bugs: (a) `buildSqlFieldValue()` read the field via `$this->$field`, which for a storage field
376
377
  triggers `__get()` → a disk/S3 **refetch** that overwrote the just-assigned value before `save()`
@@ -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,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: ["bala"]
9
+ updated: 2026-09-23
10
+ owners: ["bala", "jcardinal"]
11
11
  files:
12
12
  - worker2/Worker/Netsuite/SalesOrder.php
13
13
  - test/@Bala/tests/netsuite_salesorder_payload_tests.php
@@ -89,6 +89,14 @@ Flow:
89
89
  The internalId is written back to `SalesOrders.c_netsuiteInternalSalesOrderId` by the shared
90
90
  `writeBackNetSuiteId()` (three paths now save it).
91
91
  7. `dryRun = true` builds and returns the payload without calling NetSuite.
92
+ 8. **Non-prod send guard (2026-09-23).** `doCreate`/`doUpdate` check `_Environment::isDebugMode()`
93
+ after the dry-run return, before any NetSuite read/write or Toga write-back; if true they return
94
+ `{"action":"skippedDebugMode","intendedAction":"create|update","salesOrderUuid":…}`
95
+ (`buildDebugModeSkipResult()`). Covers CreateNetSuite, CreateNetSuiteFromTransferOrder,
96
+ UpdateNetSuite, Sync (all funnel through doCreate/doUpdate). Job records success. `dryRun` still
97
+ runs in debug mode. Inbound webhooks unaffected. Why: only one NetSuite env (prod); non-prod
98
+ worker2 was sending orders to it. Callers: `_underscore` Model/Client/TransferOrder.php,
99
+ Model/Adyen/SalesOrder.php, Model/Elite/SalesOrder.php via `_Worker::runTask`.
92
100
 
93
101
  ### The shared builder now serves two order types — the changes were additive and defaulted
94
102
 
@@ -230,6 +238,10 @@ the transfer-order work.
230
238
 
231
239
  ## Gotchas
232
240
 
241
+ - **⚠ `_Environment::isDebugMode()` returns TRUE when the `[_underscore]` config group is missing**
242
+ (fail-safe to debug). A prod config missing that group, or with debug on, silently stops ALL
243
+ NetSuite order sends — jobs still show success with `skippedDebugMode`. Prod must set
244
+ `[_underscore]` with debug off. Seeing `skippedDebugMode` in prod `WorkerJobs` = config problem.
233
245
  - **⚠ Still unverified: can SuiteQL FILTER on `transaction.externalid` in this account?** A `created`
234
246
  result **cannot distinguish** *"the lookup ran and found nothing"* from *"the lookup is broken and
235
247
  always finds nothing."* This project has already hit a NetSuite column that could be `SELECT`ed but
@@ -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**.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.877",
3
+ "version": "1.0.879",
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",