toga-ai 1.0.822 → 1.0.824

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: Tools
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-04
10
- owners: [ajean]
9
+ updated: 2026-09-16
10
+ owners: [ajean, bala]
11
11
  files:
12
12
  - tools/_/app/compass.php
13
13
  - tools/mvc/compass/users/get.php
@@ -101,6 +101,10 @@ conditions of the decision below — **do not remove one without re-opening it**
101
101
  (the same human on two `Users` rows).
102
102
  5. The four `ensureCrossRegionUsersActive()` addresses are **blocked as both source and target**
103
103
  (`isCrossRegionProtectedEmail()`), because that hardcoded worker2 list matches on email.
104
+ **⚠ Out of date as of 2026-09-16:** worker2 split that list into six addresses
105
+ (`ALWAYS_ACTIVE_EMAILS_BOTH` + `ALWAYS_ACTIVE_EMAILS_CA`), so the two new
106
+ `@compassdigital.io` Canada-protected addresses are **not** blocked here yet. See
107
+ [PEOPLE-File User Lifecycle](../../../clients/compass-usa/features/people-file-user-lifecycle.md).
104
108
  6. The **audit row is written before the write**, on its own connection (see the transaction
105
109
  gotcha below).
106
110
 
@@ -191,9 +195,14 @@ names, reads `Core.ClientEmailDomains` / `Core.Clients`, and inserts into `Logs_
191
195
  ~178,615 of 178,616 `Client_Compass` rows, so the client id cannot be derived from the user row.
192
196
  See [Compass Canada profile](../../../clients/compass-canada/profile.md).
193
197
  - **A cross-region user still needs a code change and a deploy.** The only durable way to hold one
194
- active is the hardcoded four-address list in `ensureCrossRegionUsersActive()` in
198
+ active is the hardcoded list in `ensureCrossRegionUsersActive()` in
195
199
  `worker2/Worker/Client/Compass/PeopleFile.php`. This tool does not change that — and because that
196
- list matches on **email**, those four addresses are blocked from this tool's email edit entirely.
200
+ list matches on **email**, those addresses are blocked from this tool's email edit entirely.
201
+ **As of 2026-09-16 that list is six addresses, not four**: `ALWAYS_ACTIVE_EMAILS_BOTH` (the
202
+ original four, held active in both tenants) plus `ALWAYS_ACTIVE_EMAILS_CA` (two
203
+ `@compassdigital.io` US staff held active in `Client_CompassCanada` only).
204
+ `isCrossRegionProtectedEmail()` here still only knows the original four, so the two new addresses
205
+ can be edited in this tool and silently lose their protection.
197
206
  - **Every user search is a full table scan.** `Client_Compass.Users` has no index on `email`,
198
207
  `c_hrEmpUsername`, `c_hrEmpPersonnelNbr` or `isActive` (176k rows; `EXPLAIN` reports `type=ALL`,
199
208
  no possible keys). Same on `Client_CompassCanada`. Keep result sets bounded and do not add
@@ -235,12 +244,22 @@ names, reads `Core.ClientEmailDomains` / `Core.Clients`, and inserts into `Logs_
235
244
  (`_underscore/Model/Compass/SalesOrder.php:287-305`): it joins
236
245
  `INNER JOIN Contacts ON Contacts.id = Users.id`, which should be `Users.contactId`.
237
246
  - **worker2** — scope `ensureCrossRegionUsersActive()` to a `Users.id` list instead of matching on
238
- email; that would also lift this tool's block on those four addresses.
247
+ email; that would also lift this tool's block on those addresses.
248
+ - **tools** — extend `isCrossRegionProtectedEmail()` to the two `ALWAYS_ACTIVE_EMAILS_CA` addresses
249
+ added in worker2 on 2026-09-16, or the block silently under-covers the protected set.
239
250
  - **ops** — rotate every secret in `tools/config.production.ini`; consider a least-privilege
240
251
  Compass account.
241
252
 
242
253
  ## Change history
243
254
 
255
+ - 2026-09-16 — Recorded that worker2's cross-region protected list **grew from four addresses to
256
+ six** (`ALWAYS_ACTIVE_EMAILS_BOTH` + `ALWAYS_ACTIVE_EMAILS_CA`), so this tool's
257
+ `isCrossRegionProtectedEmail()` block now under-covers it: the two new `@compassdigital.io`
258
+ Canada-protected addresses can still be email-edited here and would silently drop out of the
259
+ protected set, because the worker2 match is on email. No `tools` code changed. Detail:
260
+ [PEOPLE-File User Lifecycle](../../../clients/compass-usa/features/people-file-user-lifecycle.md).
261
+ (bala)
262
+
244
263
  - 2026-09-04 — Added **email, phone and cost-center editing** (PR tools#14,
245
264
  `feature/compass-user-field-edits`, **open / not merged / not deployed**): new `App_Compass`
246
265
  setters plus the `normaliseEmail` / `normalisePhone` / `emailDomainIsRegistered` /
@@ -61,4 +61,5 @@
61
61
  | [USPS DPV Deliverability Verdict (is this address actually insurable/shippable?)](features/usps-dpv-deliverability.md) | **USPS returning HTTP 200 with a populated address is NOT evidence that the address is deliverable.** The authoritative signal is USPS's **DPV (Delivery Point V |
62
62
  | [Refreshing a Local Dev Database from Beta (dev-sandbox)](workflows/local-db-refresh-from-beta.md) | How to reset a local 2.0 dev database from the **beta / dev-sandbox** environment: dump each schema (`Core`, `Client_<Id>`, `Logs_<Id>`, …) from the beta host, |
63
63
  | [Deleting a shared branch does not remove bad commits — a stale local clone merges them back](workflows/recreated-shared-branch-stale-local-remerge.md) | **Deleting and recreating a shared environment branch removes only the *ref*.** Every teammate who still has that branch checked out locally keeps the full pre- |
64
+ | [Rotating Public TLS Certificates (ACM, CloudFront, ALB, Elastic Beanstalk)](workflows/rotating-public-tls-certificates.md) | How to replace the public TLS certificates that terminate HTTPS for TOGa front-end domains (togahub, togacommerce, togadesk, togaretail, togasupply, togaview, t |
64
65
  | [Running a 2.0 App Locally (browser, end-to-end via api2)](workflows/running-a-2.0-app-locally.md) | The full dependency chain required to run a 2.0 client app **through the browser**, end-to-end, against a **local `api2`** (e.g. |
@@ -0,0 +1,178 @@
1
+ ---
2
+ title: Rotating Public TLS Certificates (ACM, CloudFront, ALB, Elastic Beanstalk)
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: workflow
8
+ status: active
9
+ updated: 2026-09-16
10
+ owners: ["rgirish"]
11
+ files: []
12
+ related:
13
+ - 2.0/standards/ssl-certificate-trust-model.md
14
+ - 2.0/apps/saml/workflows/rotating-the-saml-sp-certificate.md
15
+ - 1.0/apps/tools/features/cloudfront-client-setup.md
16
+ ---
17
+
18
+ ## Summary
19
+
20
+ How to replace the public TLS certificates that terminate HTTPS for TOGa front-end domains
21
+ (togahub, togacommerce, togadesk, togaretail, togasupply, togaview, togaiq) before they
22
+ expire. This is the browser-facing TLS cert on CloudFront and ALBs — **not** the SAML SP
23
+ credential (that is a pinned bilateral cert, see
24
+ [Rotating the SAML SP Certificate](../../saml/workflows/rotating-the-saml-sp-certificate.md)).
25
+
26
+ This runbook was written from the 2026-09-16 rotation in AWS account **654654170868**, where
27
+ one IMPORTED multi-domain cert (14 SANs, copied into us-east-1, us-west-2 and eu-west-1) served
28
+ 4 CloudFront distributions and 12 ALB listeners and expired the same day.
29
+
30
+ **The three things that bite you:**
31
+ 1. **Elastic Beanstalk keeps its own saved cert setting**, separate from the ALB listener. Fixing
32
+ the listener is not enough — the next deploy puts the old cert straight back.
33
+ 2. **CloudFront holds exactly ONE cert per distribution.** There is no staging. The swap *is* the
34
+ cutover, and it takes 5-15 minutes.
35
+ 3. **An ACM wildcard matches exactly one label.** `*.togasupply.com` covers
36
+ `elite.togasupply.com` but **not** `compass.beta.togasupply.com`.
37
+
38
+ **Root cause to avoid repeating:** an **IMPORTED** ACM cert never auto-renews. Always replace with
39
+ **Amazon-issued, DNS-validated** ACM certs, which renew themselves.
40
+
41
+ ## When to run this
42
+
43
+ - A cert is nearing expiry (ACM emails, or your own audit finds it).
44
+ - You are moving off an imported cert onto Amazon-issued certs.
45
+ - Any time you suspect a cert is expired — run the audit in step 1 regardless; it is cheap.
46
+
47
+ ## Step 1 — Audit EVERYTHING, not just the cert you know about
48
+
49
+ Do **not** just follow the expiring cert's `InUseBy` list. Enumerate **all** CloudFront
50
+ distributions and **all** ALB listeners in **every** region, resolve each attached cert's
51
+ `NotAfter`, and flag anything expiring before your cutoff.
52
+
53
+ This is how the 2026-09-16 rotation found a **second** cert that had already expired six months
54
+ earlier (2026-03-24) and was still the only cert on 2 ALB listeners and 2 CloudFront
55
+ distributions — 4 live togadesk hosts had been serving cert errors since March and nobody noticed.
56
+
57
+ Also audit **every Elastic Beanstalk environment's saved config**, including suspended ones. A
58
+ suspended environment with no live listener can still hold a stale cert ARN that comes back when
59
+ it is rebuilt.
60
+
61
+ **Check each CloudFront distribution's origins before you plan work on it.** One distribution in
62
+ this rotation had a real alias and live DNS but all 3 origins pointed at load balancers that no
63
+ longer existed — a dead leftover. Do not spend change-window time on it.
64
+
65
+ ## Step 2 — Decide the cert shape: one cert per domain
66
+
67
+ Replace a bundled multi-domain cert with **one cert per domain** (root + wildcard, e.g.
68
+ `togahub.com` + `*.togahub.com`). Reasons:
69
+
70
+ - A 14-SAN request exceeds ACM's default 10-domain quota.
71
+ - One domain's problem no longer takes down all seven.
72
+ - Each distribution only needs the domain it actually serves.
73
+
74
+ All new certs must be **Amazon-issued and DNS-validated** so they auto-renew. This is case 2 of
75
+ the [SSL Certificate Trust-Model Strategy](../../../standards/ssl-certificate-trust-model.md):
76
+ AWS terminates the TLS, so use ACM public, non-exportable, auto-renewing.
77
+
78
+ ## Step 3 — Request the certs in the right regions
79
+
80
+ Region rules:
81
+
82
+ - **CloudFront only reads certs from `us-east-1`.** No exceptions.
83
+ - **An ALB reads certs from its own region.** A togahub ALB in us-west-2 needs a us-west-2 cert;
84
+ the same domain on an eu-west-1 ALB needs a separate eu-west-1 cert.
85
+
86
+ Check ACM first — unused per-domain certs may already exist (3 did in this rotation). Then request
87
+ only what is missing.
88
+
89
+ ## Step 4 — Validate by DNS in Route 53
90
+
91
+ Add the validation CNAMEs to Route 53. Certs for the **same domain in different regions share ONE
92
+ validation record** — the CNAME name and value are identical, so 6 certs across 3 regions needed
93
+ only 5 CNAMEs.
94
+
95
+ Validation completes in about 2 minutes once the records are live. Wait for `ISSUED` before
96
+ touching anything.
97
+
98
+ ## Step 5 — Stage the ALB listeners first (safe, additive)
99
+
100
+ `aws elbv2 add-listener-certificates` is **purely additive**. A listener holds many certs and picks
101
+ by SNI, so adding the new cert alongside the old one changes nothing for live traffic. Do this
102
+ ahead of the change window for every listener.
103
+
104
+ ## Step 6 — THE ONE PEOPLE MISS: update the Elastic Beanstalk saved config
105
+
106
+ Adding a cert to an ALB listener does **not** update the EB environment's saved setting
107
+ `aws:elbv2:listener:443 / SSLCertificateArns`. After all 12 listeners were fixed and verified
108
+ healthy in this rotation, 12 EB environments still named the **old** cert in their saved config.
109
+
110
+ Nothing breaks right away — the listener serves traffic. But the next **deploy, restart, rebuild or
111
+ scaling event** re-applies the saved config and puts the expired cert back. A listener-level audit
112
+ does not catch this.
113
+
114
+ ```
115
+ aws elasticbeanstalk update-environment \
116
+ --environment-name <env> \
117
+ --option-settings "Namespace=aws:elbv2:listener:443,OptionName=SSLCertificateArns,Value=<new-arn>"
118
+ ```
119
+
120
+ Observed behaviour: this cleanly **replaced** the old cert on the listener (leaving only the new
121
+ one), and every environment stayed Green/Ok through the update — no traffic disruption.
122
+
123
+ **Gotcha:** an EB environment whose CloudFormation stack is in `DELETE_FAILED` cannot be updated at
124
+ all — `update-environment` is refused. That needs separate stack cleanup.
125
+
126
+ ## Step 7 — Check alias coverage before any CloudFront swap
127
+
128
+ CloudFront **rejects** an update if any alias on the distribution is not covered by the new cert.
129
+ Check every alias against the new cert's SANs **programmatically** — this is where the one-label
130
+ wildcard rule bites: `*.togasupply.com` does not cover `compass.beta.togasupply.com`. Beta and
131
+ gamma environments generally need their own certs for this reason.
132
+
133
+ ## Step 8 — Swap CloudFront, smallest blast radius first
134
+
135
+ `ViewerCertificate.ACMCertificateArn` is a **single string**, not a list. There is no
136
+ "add new alongside old" for CloudFront: the swap is the cutover, and it deploys in 5-15 minutes.
137
+
138
+ Order used, lowest risk first:
139
+
140
+ 1. Distributions that are **already broken** (nothing to lose).
141
+ 2. Single-alias distributions.
142
+ 3. Mid-size (9 aliases).
143
+ 4. The largest last (the 21-alias distribution fronting 21 named enterprise clients).
144
+
145
+ Back up each distribution's config before every swap — `aws cloudfront get-distribution-config`,
146
+ and keep the **ETag**; you need it to update.
147
+
148
+ **Hard edge:** once the old cert's expiry moment has passed there is no useful CloudFront rollback,
149
+ because rolling back restores an expired cert. Only forward.
150
+
151
+ ## Step 9 — Verify on the live hosts
152
+
153
+ ```
154
+ echo | openssl s_client -servername HOST -connect HOST:443 2>/dev/null \
155
+ | openssl x509 -noout -issuer -enddate
156
+ ```
157
+
158
+ Also run it **without** `-servername` to check the no-SNI / default-cert path — that path can still
159
+ be serving the old cert after everything else looks fine.
160
+
161
+ Finish by re-running the step 1 audit: every CloudFront distribution, every ALB listener, and every
162
+ EB saved config should name a cert with a future `NotAfter`.
163
+
164
+ ## Gotchas
165
+
166
+ - **IMPORTED certs never auto-renew.** If the audit shows `Type: IMPORTED`, it is a future outage.
167
+ - **A cert copied into 3 regions is 3 separate certs** — all 3 expire at once, and all 3 need
168
+ replacing.
169
+ - **EB saved config is the silent landmine.** See step 6.
170
+ - **CloudFront takes one cert; ALBs take many.** Two completely different risk profiles in the same
171
+ rotation — plan them separately.
172
+ - **Wildcards match one label only.** Verify alias coverage with code, not by eye.
173
+ - **Expired certs hide.** Sweep the whole account; do not trust `InUseBy`.
174
+
175
+ ## Change history
176
+ - 2026-09-16 — Created from the account 654654170868 rotation: replaced an expiring 14-SAN IMPORTED
177
+ cert with 6 per-domain Amazon-issued DNS-validated certs, relinked 12 ALB listeners, 6 CloudFront
178
+ distributions and 11 EB environment configs; documented the EB saved-config trap. (rgirish)
@@ -19,7 +19,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
19
19
 
20
20
  ## 2.0 framework
21
21
 
22
- - **_underscore** (_Underscore) _(framework core)_ — 85 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
+ - **_underscore** (_Underscore) _(framework core)_ — 86 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
23
23
  - **worker2** (Worker) — 69 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
24
24
  - **api2** (API) — 26 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
25
25
  - **dbchanges2** (Database Changes) _(framework core)_ — 19 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-04
9
+ updated: 2026-09-16
10
10
  owners: [bala, ajean]
11
11
  files:
12
12
  - worker2/Worker/Client/Compass/PeopleFile.php
@@ -85,6 +85,39 @@ insert-refresh time the **new row already has its id** and the **old row is stil
85
85
  deactivation-time hook would be worse — by then the new row's relationship to the old one has to be
86
86
  rediscovered, and the deactivation path has no model hooks at all (see below).
87
87
 
88
+ ### Always-active protection is per-tenant, and has two layers
89
+
90
+ A small hardcoded set of people must stay active regardless of what HR sends. Since **2026-09-16**
91
+ that set is split into two class constants on `_Worker_Client_Compass_PeopleFile`:
92
+
93
+ - **`ALWAYS_ACTIVE_EMAILS_BOTH`** — the original four addresses (two `@compass-usa.com`, two
94
+ `@compass-canada.com`). Held active in **both** `Client_Compass` and `Client_CompassCanada`.
95
+ - **`ALWAYS_ACTIVE_EMAILS_CA`** — two `@compassdigital.io` **Compass USA** staff (usernames
96
+ `shuttk01` / `racerk01` in `Client_Compass`). Held active in **`Client_CompassCanada` only**.
97
+ They are allowed to go inactive in the US tenant when the PEOPLE file drops them; that is the
98
+ requirement, not a bug.
99
+
100
+ `isEmailAlwaysActive(string $email, string $tenantKey): bool` is the single decision point — a
101
+ lowercased match that returns true for the BOTH list in either tenant, and for the CA list only when
102
+ `$tenantKey === 'CA'`. Two layers consume it:
103
+
104
+ 1. **The inactivation loop** in `executeForTenant()` skips any user whose `Users.email` is
105
+ protected for that tenant, so the batch `UPDATE ... SET isActive = 0` never lists them.
106
+ 2. **`ensureCrossRegionUsersActive()`**, which runs after both tenants have executed and force-sets
107
+ `isActive = 1` — in both databases for the BOTH list, and in `Client_CompassCanada` only for the
108
+ CA list.
109
+
110
+ **Today only layer 2 actually protects the CA-only pair.** In `Client_CompassCanada` both of those
111
+ users have `c_hrEmpUsername = NULL`, and `loadLookups()` keys its map on username — so they never
112
+ enter the map and can never reach the inactivation batch on the Canada side. The loop guard is the
113
+ defence for the day HR gives them a Canada username, not what is holding them active now.
114
+ (Read-only prod check, 2026-09-16.)
115
+
116
+ **The email match works across a case difference.** `Client_Compass` stores these two addresses
117
+ UPPERCASE while `Client_CompassCanada` stores them lowercase. `Users.email` uses
118
+ `utf8mb4_0900_ai_ci`, a case-insensitive collation, so `WHERE email = '...'` still hits — each
119
+ address resolves to exactly **one** row per database.
120
+
88
121
  ## Gotchas
89
122
 
90
123
  - **⚠ Deactivation is raw bulk SQL — model hooks never fire.** User inactivation is executed as
@@ -157,16 +190,26 @@ rediscovered, and the deactivation path has no model hooks at all (see below).
157
190
  makes the [Tools Compass admin
158
191
  pages](../../../1.0/apps/tools/features/compass-user-persona-admin.md) a one-way-durable tool.
159
192
  - **`ensureCrossRegionUsersActive()` runs `UPDATE Users SET isActive = 1 WHERE email = X` with no
160
- `isActive` filter**, so it reactivates **every** row carrying one of its four hardcoded addresses,
161
- dead duplicates included. Verified on prod 2026-09-02: each of the four addresses currently
162
- resolves to exactly **one** active row in **both** tenants (zero inactive), so nothing is being
163
- resurrected today — but the query would happily do it if a duplicate appeared. That hardcoded list
164
- is also still the **only** durable way to hold a cross-region user active, and changing it needs a
165
- code change and a deploy per request. **Recommended (not done): scope it to a `Users.id` list
166
- instead of matching on email** — matching on email means an email edit silently moves a user in or
167
- out of the protected set, which is why the [Tools Compass
168
- admin](../../../1.0/apps/tools/features/compass-user-persona-admin.md) blocks those four addresses
169
- from being edited at all.
193
+ `isActive` filter**, so it reactivates **every** row carrying a protected address, dead duplicates
194
+ included. Verified on prod 2026-09-02 for the original four, and 2026-09-16 for the two CA-only
195
+ addresses: each resolves to exactly **one** row per database, so nothing is being resurrected
196
+ today — but the query would happily do it if a duplicate appeared. That hardcoded list is also
197
+ still the **only** durable way to hold a cross-region user active, and changing it needs a code
198
+ change and a deploy per request. **Recommended (not done): scope it to a `Users.id` list instead
199
+ of matching on email** — matching on email means an email edit silently moves a user in or out of
200
+ the protected set, which is why the [Tools Compass
201
+ admin](../../../1.0/apps/tools/features/compass-user-persona-admin.md) blocks the protected
202
+ addresses from being edited at all.
203
+ - **⚠ The Tools admin's edit block covers only the original four addresses, not the two CA-only
204
+ ones.** The protected set grew to six in worker2 on 2026-09-16; the `tools` block list was not
205
+ changed and was not re-checked. Until it is, someone can edit the email of a CA-protected user in
206
+ [Tools Compass admin](../../../1.0/apps/tools/features/compass-user-persona-admin.md) and silently
207
+ drop them out of the protected set — because the match is on email, not id.
208
+ - **⚠ Adding a new "always keep this person active" rule means touching exactly two places.**
209
+ `executeForTenant()`'s inactivation loop is the only `isActive = 0` writer in the whole class, and
210
+ `ensureCrossRegionUsersActive()` is the post-run sweep. Guard both and the requirement is covered;
211
+ guard only the loop and the post-run sweep still decides the final state, guard only the sweep and
212
+ the user flickers inactive between the two steps.
170
213
 
171
214
  ## Remediation — BUILT 2026-08-20
172
215
 
@@ -197,6 +240,24 @@ question is now contained rather than blocking.
197
240
 
198
241
  ## Change history
199
242
 
243
+ - 2026-09-16 — **Made the always-active protection tenant-aware.** Two Compass USA staff
244
+ (`@compassdigital.io`, usernames `shuttk01` / `racerk01`) exist in **both** tenants, but the
245
+ PEOPLE file lists them only as US rows (`c_erpSystemId` 1001), so they were routed to
246
+ `Client_Compass` alone and had no protection on the Canada side. Requirement: they may go inactive
247
+ in the USA per the file, but must **always** stay active in Canada. Split the previously
248
+ undocumented single hardcoded list into `ALWAYS_ACTIVE_EMAILS_BOTH` (the original four) and
249
+ `ALWAYS_ACTIVE_EMAILS_CA` (the two new ones), added
250
+ `isEmailAlwaysActive(string $email, string $tenantKey): bool` as the single lowercased decision
251
+ point, and wired it into **both** the `executeForTenant()` inactivation loop (skip protected
252
+ users) and `ensureCrossRegionUsersActive()` (both DBs for BOTH, `Client_CompassCanada` only for
253
+ CA). Prod read-only findings: in `Client_CompassCanada` both users have `c_hrEmpUsername = NULL`,
254
+ so they never enter `loadLookups()`'s username-keyed map and **cannot** reach the Canada
255
+ inactivation batch at all — the post-run sweep is what actually protects them today, and the loop
256
+ guard is defence for the day they get a Canada username. Also confirmed the two tenants store
257
+ these addresses in different case (US uppercase, CA lowercase) and the email match still works
258
+ because `Users.email` uses the case-insensitive `utf8mb4_0900_ai_ci` collation, one row per
259
+ address per database. Not committed or pushed — worker2 working tree only. (bala)
260
+
200
261
  - 2026-09-04 — Prod-verified the **full index list** on `Users` in both tenants and recorded the
201
262
  load-bearing consequence: **there is no `UNIQUE KEY` on `email`** (and no index on
202
263
  `c_hrEmpUsername` / `c_hrEmpPersonnelNbr` / `isActive`), so the database silently accepts duplicate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.822",
3
+ "version": "1.0.824",
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",