toga-ai 1.0.871 → 1.0.873

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.
@@ -7,6 +7,7 @@
7
7
  | [Where a new App_ class goes — the app/ folder IS a behavioral contract](features/app-class-placement-base-contracts.md) | Where a new `App_` class goes — each `app/` folder's base class carries real inherited behavior; open before creating any class under `app/model|client|api`. |
8
8
  | [App_Sso — Reusable 1.0 SSO Initiation (SP-initiated SAML via saml.togahub.com)](features/app-sso-initiation.md) | Reusable 1.0 SP-initiated SAML SSO via `saml.togahub.com` (`App_Sso::initiate`); the canonical doc to follow for any "add SSO to `<1.0 app>`" request. |
9
9
  | [Cron Execution Monitoring (App_Framework check-in/out → CronJobExecutions)](features/cron-execution-monitoring.md) | 1.0 cron check-in/out lifecycle via `App_Framework::cronInitialization()`/`cronFinished()` → `Common.CronJobExecutions`; open to monitor a 1.0 cron or read its |
10
+ | [1.0 MySQL Connection Lifecycle — wait_timeout, the 2006 Reconnect Gap, keepAlive/reconnect/queryForked](features/db-connection-lifecycle-and-reconnect.md) | How a 1.0 mysqli connection dies during a long external call ("MySQL server has gone away"), why the built-in 2006 reconnect never fired, and the three helpers |
10
11
  | [Diagnostic Dialog — View Recommended Services Routing](features/diagnostic-dialog-view-recommended-services.md) | TOGa Refresh 2026 "View Recommended Services" routing — per-device diagnostic `resultCode` decides serviceselection vs techsupport; open when touching that rout |
11
12
  | [Elite Freshservice Sync (library)](features/elite-freshservice-sync.md) | Elite bidirectional ticket/attachment sync between TOGA 2 and TOGaDesk (`App_Api_Toga2::syncWithTogadesk`); open for the sync mechanics, watermarks, and Elite-s |
12
13
  | [App_Email Queued Sending & Attachments (Common.EmailsQueued)](features/email-queue-attachments.md) | 1.0 `App_Email` queued sending + attachment embedding via `Common.EmailsQueued`; open when a queued email loses attachments or when deploying worker/togadesk to |
@@ -0,0 +1,113 @@
1
+ ---
2
+ title: "1.0 MySQL Connection Lifecycle — wait_timeout, the 2006 Reconnect Gap, keepAlive/reconnect/queryForked"
3
+ framework: "1.0"
4
+ repo: library
5
+ project: Library
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-09-22
10
+ owners: ["rgirish", "jcardinal"]
11
+ files:
12
+ - library/app/database.php
13
+ - library/app/query.php
14
+ - library/app/error.php
15
+ related:
16
+ - ../architecture.md
17
+ - ./error-capture-1-0.md
18
+ - ../../standards/backend-php.md
19
+ - ../../apps/worker/features/netsuite-togasupply-per-client-sync.md
20
+ - ../../../clients/compass-usa/workflows/odp-order-pipeline-to-netsuite.md
21
+ ---
22
+
23
+ How a 1.0 mysqli connection dies during a long external call ("MySQL server has gone away"), why the built-in 2006 reconnect never fired, and the three helpers (`keepAlive`, `reconnect`, `queryForked`) that fix it; open before writing any cron that does a slow HTTP/SOAP call between two DB writes.
24
+
25
+ ## Summary
26
+
27
+ 1.0 crons hold **one** mysqli connection for the whole run. `App_Database` caches the handle, so a single slow external call (a NetSuite SOAP `add()`, a batch of api2 unit POSTs, an AI call) can leave the connection idle past the server's `wait_timeout`. Every DB statement after that fails with **"MySQL server has gone away"** — and because 1.0 turns every PHP warning into a fatal `exit`, the cron dies **mid-run, after the external side effect already happened**.
28
+
29
+ That shape produces the worst class of bug we have: **the remote system did the work, our stamp never landed, and the next run redoes it.** Two confirmed incidents — the Compass ODP duplicate NetSuite sales orders (2026-09-22) and the NetSuite → TOGa Supply item-fulfillment doom loop (2026-09-02).
30
+
31
+ **Rule for any long-running cron:** a slow external call between two DB writes needs `App_Database::keepAlive()` around it. Do not rely on `App_Query`'s reconnect, and do not rely on `try`/`catch`.
32
+
33
+ **`wait_timeout` on the production client cluster is 180 seconds** — three minutes, not the MySQL default of 8 hours. Verified 2026-09-22. Assume any external call that can exceed three minutes will kill the connection.
34
+
35
+ ## How it works
36
+
37
+ ### Why the connection dies at `mysqli_select_db`, not at your query
38
+
39
+ `App_Query::execute()` (`library/app/query.php`) does two things in order inside its retry loop:
40
+
41
+ ```php
42
+ mysqli_select_db($link, $dbConfig['dbname']); // query.php:168 <- dies here
43
+ $res = mysqli_query($link, $this->query); // never reached
44
+ $lastErrorNumber = mysqli_errno($link); // query.php:173 <- the 2006 check
45
+ ```
46
+
47
+ `mysqli_select_db()` is the **first touch** of the stale handle, so it is where the "gone away" surfaces. It raises a **PHP warning**, not a mysqli exception, so it never reaches the `mysqli_errno()` check below it.
48
+
49
+ `mysqli_report(MYSQLI_REPORT_STRICT)` is set in `App_Error::initialize()` (`library/app/error.php:252`). **STRICT raises mysqli exceptions only for CONNECTION errors** — everything else stays a warning. That single line is why a dead connection is a warning and not something you can catch.
50
+
51
+ ### The built-in 2006 reconnect exists and has never worked
52
+
53
+ `App_Query::execute()` has reconnect logic for MySQL error **2006** at `query.php:175` — `databaseClose()` + `registerDatabaseConnect()` + retry, capped at two attempts by `$connectionAttemptsToMake = 2` (`query.php:85`). It is dead for **three independent reasons**, and fixing any one alone changes nothing:
54
+
55
+ 1. **Unreachable on the warning path.** The warning fires at `mysqli_select_db()` (`:168`) before execution ever reaches the `mysqli_errno()` check (`:173`).
56
+ 2. **The connection cache hands back the same dead handle.** `App_Database::databaseClose()` unset the `App_Registry` entry but never cleared `App_Database::$connections[$hostname]`. `databaseConnect()` short-circuits on `if (!isset(self::$connections[$hostname]))`, so the "reconnect" returns the **identical dead link**.
57
+ 3. **`$link` is captured before the loop.** `query.php:97` reads `$link` once, above the `do { } while` retry, so even a genuinely successful reconnect never reaches the retried query.
58
+
59
+ ### A `try`/`catch` around a DB call cannot help
60
+
61
+ `App_Error::handleError()` (`library/app/error.php:364`) converts **every** PHP warning into an `ErrorException` and hands it to `App_Error::handleException()`, which ends in `exit` (`error.php:671`). The process dies **inside the warning handler** — it never unwinds to your `catch`.
62
+
63
+ So a `try { $stamp->save(); } catch (Exception $e) { emailDevteam(); }` around a DB write is **dead code on the warning path**. Proven empirically 2026-09-22: `Logs.Issue` 769 recorded **33 occurrences** of the gone-away warning while `Logs_Compass.Email` held **zero** matching alert emails. The error-email block was never reached, not once.
64
+
65
+ Canonical mechanics and the two sanctioned local opt-outs are in the [back-end standard](../../standards/backend-php.md#any-php-warning-terminates-the-request--how-to-opt-out-locally). This doc records what it means specifically for DB calls.
66
+
67
+ ### The three helpers
68
+
69
+ | Helper | Where | Use it when |
70
+ |---|---|---|
71
+ | `App_Database::keepAlive($link)` | `database.php:740` | **The default.** Call it immediately before and immediately after a slow external call. It is just `SELECT 1` on that link. |
72
+ | `App_Database::reconnect($link)` | `database.php:446` | The connection is already presumed dead and you must get a working one (e.g. inside a shutdown handler). |
73
+ | `App_Database::queryForked($sql, $link)` | `database.php:465` | A write you want to run in a **separate PHP process**, so the parent cron never holds its connection open across it. Falls back to a direct `query()` on Windows. |
74
+
75
+ **`reconnect()` is the fix for cache bug #2 above** — it is the only thing that actually forces a new handle:
76
+
77
+ ```php
78
+ self::databaseClose($linkRegistryString);
79
+ unset(self::$connections[$config[$dbConfigKey]['hostname']]); // the line databaseClose() is missing
80
+ self::registerDatabaseConnect($linkRegistryString);
81
+ ```
82
+
83
+ It returns **`false`** (rather than throwing) when the link has no config entry, because callers reconnect several links at once and a link this environment does not have must not abort the rest.
84
+
85
+ ### The shutdown-handler alert pattern (for "the remote side already committed")
86
+
87
+ When a cron creates a record in an external system and then stamps our DB, a dead connection at the stamp step produces a **silent duplicate**. `register_shutdown_function()` is the **only** code that still runs after `App_Error::handleException()` calls `exit`, so it is the only place an alert can fire.
88
+
89
+ The working pattern (Compass ODP cron 5, `worker` commit `afce32ca`):
90
+
91
+ 1. **Arm** an `$unstampedNetsuiteOrder` array immediately *before* the external `add()`.
92
+ 2. **Clear** it after a successful stamp, and also clear it when the remote system *rejects* the order (nothing was created, so nothing is orphaned).
93
+ 3. In the shutdown handler, if it is still armed, email devteam.
94
+ 4. **Reconnect `db_common` and `db_logs` first — NOT the client link.** `App_Email::send()` reads the suppression list on **db_common** (`App_Model_Common_EmailSuppressionList`, `App_Model_Common_EmailsQueued`) and writes its log row on **db_logs** (`App_Model_Logs_Email`). Reconnecting the client link buys nothing for the email.
95
+ 5. Wrap it in `catch (Throwable)` falling back to `error_log()`, which needs no database at all.
96
+
97
+ ## Gotchas
98
+
99
+ - **⚠ `wait_timeout` is 180 seconds, and raising it is the wrong fix.** Rejected 2026-09-22 with evidence: DB `Uptime` was **16,955,541 s (196 days)**, so the timeout had been 180 the entire time the job worked — nothing about it changed. Raising it only **moves the cliff**, because the NetSuite call time has no upper bound. And `Aborted_clients` was already **70,855**; a longer timeout just makes dead connections linger longer. Fix the reconnect, keep the timeout.
100
+ - **⚠ A `catch` around a DB call proves nothing about whether an alert will fire.** Verify against the actual email/log table, not the code. Thirty-three failures produced zero emails.
101
+ - **⚠ Kit / bundle orders are the ones that break.** Nothing about a part number causes this — the slow path does. On Compass ODP, `PC-KIT-OPT2` (10 expanded lines) accounted for 10 of 11 duplicated POs simply because kits are the orders whose `add()` runs past 180 s. Read a part-number correlation as "this is the slow path", not "this SKU is special".
102
+ - **⚠ Reconnecting the wrong link is a silent no-op.** Emailing needs `db_common` + `db_logs`. A handler that only reconnects the client link will still die trying to send.
103
+ - **⚠ Turning a latent version of this bug ON at volume is a separate risk.** The Compass race existed from 2026-09-15 but the cron was `"active": 0`, so it barely fired. Flipping it to `1` on 2026-09-21 produced 15 duplicate NetSuite SOs in two days. When re-enabling a long-dormant cron that writes to an external system, check its stamp path first.
104
+ - **`keepAlive()` after the call must sit OUTSIDE the `try`/`catch`** and be guarded on the response being non-null, so a rejected call does not run it against state you have not checked.
105
+
106
+ ## Decisions
107
+
108
+ - **2026-09-22 — Fix the reconnect; do not raise `wait_timeout`.** Evidence above. CTO review of the broader question ("should `App_Error::handleException()` throw instead of `exit`?") returned **DISAGREE-WITH-ALTERNATIVE**: fix the reconnect first (the B+ option) and earn the error-handler change later with a week of log-only data. Two dangers it flagged for anyone who revisits that `exit`:
109
+ - `library/app/error.php:396` calls `rollbackTransaction()` before exiting. If warnings started throwing, code would **resume after a silent rollback it does not know happened** — and 1.0 transactions are global (see the [back-end standard](../../standards/backend-php.md#app_database-layer)).
110
+ - `library/app/error.php:5-8` has `IMMEDIATELY_TERMINATE_INSTANCE_IF_ERROR_STRING_CONTAINS`, matched **on substring before severity**. A recoverable warning could terminate an EC2 instance.
111
+
112
+ ## Change history
113
+ - 2026-09-22 — Doc created. Root-caused the "gone away" class: warning fires at `mysqli_select_db` before the 2006 check, `databaseClose()` never cleared `$connections`, `$link` captured above the retry loop. Added `App_Database::reconnect()` + `queryForked()` (`library` commit `60fdb888`) and documented the shutdown-handler alert pattern. Decided against raising `wait_timeout` from 180 s. (rgirish)
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-21
9
+ updated: 2026-09-22
10
10
  owners: ["dfranks", "bala", "jcardinal", "mhammontree", "snaredla", "rgirish"]
11
11
  files:
12
12
  - worker/crons/toga2/netsuite/common_sync_togasupply.php
@@ -1886,6 +1886,15 @@ library (or vice versa) crashes GroWrk and Adyen on their next sync run.
1886
1886
  `database.php:431`) only guards the `mysqli_query` call (errno **2006** reconnect), **NOT** the
1887
1887
  `mysqli_select_db` that runs before it — which is the first touch of the stale connection, so the
1888
1888
  reconnect never gets a chance. A durable framework gap, not sync-specific.
1889
+ - **Update 2026-09-22 — fully root-caused, and the framework-side tooling now exists.** There are
1890
+ **three** reasons that 2006 reconnect never worked, not one: the warning path above, plus
1891
+ `databaseClose()` never clearing `App_Database::$connections[$hostname]` (so `databaseConnect()`
1892
+ hands back the **same dead handle**), plus `$link` being captured at `query.php:97` above the retry
1893
+ loop. Prod `wait_timeout` is **180 seconds**, not 8 hours. `App_Database::reconnect()` (clears the
1894
+ cache slot) and `queryForked()` landed in `library` `60fdb888`; `keepAlive()` around the long call is
1895
+ the standing fix. **This sync has NOT yet been given `keepAlive()` around its unit-POST phase** — the
1896
+ keyset cursor is still the only mitigation here. Full mechanics and the shutdown-handler alert
1897
+ pattern: [1.0 MySQL Connection Lifecycle](../../library/features/db-connection-lifecycle-and-reconnect.md).
1889
1898
  - **Durable engine rule reconfirmed:** a shrinking window in this sync **ALWAYS** means a thrown
1890
1899
  exception — there is **no** volume/time/memory backoff anywhere in the script. "Window not 864000" ⇒
1891
1900
  something threw. The log-connection idle-out is a concrete cause to add to the poison-record and
@@ -2062,6 +2071,11 @@ library (or vice versa) crashes GroWrk and Adyen on their next sync run.
2062
2071
  [NetSuite Sync Alert Monitor](../../library/features/netsuite-sync-alert-monitor.md).
2063
2072
 
2064
2073
  ## Change history
2074
+ - 2026-09-22 — **The `Logs.Issue` 589 doom-loop's framework root cause is now fully explained and has
2075
+ tooling, but this sync is NOT yet patched.** Three independent reasons the 2006 reconnect was dead
2076
+ (warning path, `$connections` cache never cleared, `$link` captured above the retry loop); prod
2077
+ `wait_timeout` is 180 s. `App_Database::reconnect()` + `queryForked()` added in `library` `60fdb888`.
2078
+ Applying `keepAlive()` around this sync's long unit-POST phase remains open. (rgirish)
2065
2079
  - 2026-09-21 — **NYCHH INVENTORY_ADJUSTMENTS unfrozen (library, deployed + prod-verified).** Paged the
2066
2080
  existing-`ItemFulfillmentItems` dedup lookup in `syncItemFulfillmentFromNetsuite` (it read a truncated
2067
2081
  nested collection off a `depth=4` header GET past the 25-row cap and re-created duplicate
@@ -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: [bala]
9
+ updated: 2026-09-22
10
+ owners: [bala, tcox]
11
11
  files:
12
12
  - _underscore/Model/Client/Users/DelegatorUser.php
13
13
  - _underscore/Model/Client/User.php
@@ -119,20 +119,27 @@ paging. Two hard rules in
119
119
  - **Outbound logs never carry `userId` or `originalUserId`.** `_underscore/ApiRequest.php` and
120
120
  `SoapRequest.php` write `direction = OUT` rows for calls TOGa makes to NetSuite/UPS — there is no
121
121
  signed-in user on those. A CodeRabbit review asking for the stamp there was wrong and rejected.
122
+ - **api2 does log 4xx rows** (59 × 403 on client-sandbox in the last 30 days), so a missing 403 in
123
+ `Logs_<Client>.Api` means the call never happened or hit another environment — check the host
124
+ before blaming the log.
122
125
  - **Role 3 (API) cannot reach this record.** Its grant for record 362 was written into `Core`, but
123
126
  `aclDatabase = CLIENT` means Core rows are never consulted — so no integration can read or write
124
127
  `user-delegator-users` through api2 until the grant is mirrored into each `Client_*` DB.
125
128
 
126
129
  ## Still open (next person: do not rediscover)
127
130
 
128
- 1. **The front end does not switch the token, so the feature is not real yet.** The header dropdown
129
- only saves the pick to localStorage and passes it as a cart form value; it never calls
130
- `/auth/delegator` (only admin View As does, in `NavIconList.tsx`). With that, `$api->delegator` is
131
- never set, `originalUserId` stays NULL, the gate never fires, and the buyer browses their **own**
132
- catalog and prices while "ordering for" someone else. Required switch: `GET
133
- /users/encryptedUser?uuid=<target>` → `POST /auth/delegator` → `beginDelegation(tokens,
134
- originalUserUuid)` (`src/api/sessionTokens.ts`); `restoreOriginalTokens()` returns with no new
135
- login. **One parked-token slot, shared with View As** — the two cannot nest.
131
+ 1. ~~The front end does not switch the token~~ — **done 2026-09-22.** Commerce now runs
132
+ `encryptedUser` → `/auth/delegator` → `beginDelegation` → reload on every Order For pick; see
133
+ [header order-for dropdown](../../toga2-commerce/features/header-order-for-dropdown.md). Two
134
+ findings from that session are still open:
135
+ - **`originalUserId` is not being stamped on client-sandbox.** Every `Logs_Compass.Api` row of a
136
+ real Order For session (2026-09-22 11:10–11:13, `userId` 303631, including
137
+ `POST /v2/sales-orders` at 11:12:36) has `originalUserId` NULL.
138
+ - **The gate now blocks admin View As too.** `canUserOrderForOtherUser` guards
139
+ `/users/encryptedUser` and `/auth/delegator` for everyone, so View As returns **403 EZ-1** for
140
+ any target missing from the caller's `Users_DelegatorUsers` rows (api.beta, user `50160ccc`).
141
+ On prod that means View As works only for listed people. Decide whether View As needs its own
142
+ permission path.
136
143
  2. **`c_orderSubmittedById` must be stamped server side.** The column exists on `SalesOrders` in
137
144
  `Client_Compass`, `Client_CompassCanada`, `Client_Quad`, with `CustomRecordFields` id 40 on record
138
145
  14 and `_Model_Compass_SalesOrder` L51 as `FIELD_INTEGER`. It is a plain integer, not an FK, so
@@ -147,6 +154,10 @@ paging. Two hard rules in
147
154
 
148
155
  ## Change history
149
156
 
157
+ - 2026-09-22 — Front end now switches the token, so the gate is live: recorded that
158
+ `originalUserId` is still NULL on client-sandbox during a real Order For session, that the gate
159
+ also returns 403 EZ-1 for admin View As of anyone off the list, and that api2 does log 4xx rows.
160
+ Seeded 8 `Users_DelegatorUsers` rows for Dev Team on client-sandbox. (tcox)
150
161
  - 2026-09-21 — Created (TRUE-81848). `Users_DelegatorUsers` + record 362, the two-point
151
162
  `canUserOrderForOtherUser` gate, `Logs_<Client>.Api.originalUserId`, caller-scoped read-only ACL
152
163
  for Compass US/Canada (+ dev-sandbox variants), and `fetchDelegationList`. Table ships empty; the
@@ -13,7 +13,7 @@
13
13
  | [Client Fields — per-tenant / language / role content & config](features/client-fields.md) | Per-tenant/language/role JSON config that drives almost all commerce UI text, fields, and login fetches (Layer A global + Layer B FIELDS registry); open before |
14
14
  | [Config-Driven Expedited Shipping Gating (Cart)](features/expedited-shipping-gating.md) | Expedited shipping options are config-gated to show only when the cart holds a computer kit; open when touching cart shipping-method options or the gate config. |
15
15
  | [Filter / Search-Results Page & the Two Search Entry Points](features/filter-search-results-page.md) | Two search entry points (Home in-place vs Header→/filter) render the same card via different code paths and FIELDS files; open for any search-results or kit-car |
16
- | [Header "Order for <Name>" dropdown + the persisted pick handed to the cart](features/header-order-for-dropdown.md) | Header pill that lets a buyer pick who they are ordering for from any page, and the persisted store that hands that pick to the cart's order-for cascade; open b |
16
+ | ["Order For" — header pill, delegated token switch, and the cart hand-off](features/header-order-for-dropdown.md) | Header pill that switches the session to a delegated token so a buyer orders **as** someone else; open before touching the pill, `useOrderForZu`, the delegation |
17
17
  | [Inactive-item purchase gating (standalone lines only — kits are exempt by design)](features/inactive-item-purchase-gating.md) | `Items.isActive = 0` blocks standalone purchase but means something different on kit components, so the gate keys on purchase CONTEXT not the flag; open when to |
18
18
  | [Multi-Tenant Resolution & Theming](features/multi-tenant-theming.md) | How the hostname resolves the tenant and fans out to API base URL, theme (CSS variables), and fields; open when adding a tenant or debugging wrong-theme/wrong-A |
19
19
  | [Order-submit sync sequencing (useSubmitOrder) — why these calls must not run in parallel](features/order-submit-sync-sequencing.md) | The item sync must finish before the header sync on order submit, or concurrent line deletes race a depth-3 response into an uncaught api2 500; open when touchi |
@@ -1,121 +1,139 @@
1
1
  ---
2
- title: Header "Order for <Name>" dropdown + the persisted pick handed to the cart
2
+ title: "Order For" — header pill, delegated token switch, and the cart hand-off
3
3
  framework: "2.0"
4
4
  repo: toga2-commerce
5
5
  project: TOGa Commerce
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-21
9
+ updated: 2026-09-22
10
10
  owners: [tcox, bala]
11
11
  files:
12
+ - src/hooks/useOrderForDelegation.ts
13
+ - src/hooks/useReturnToOriginalUser.ts
14
+ - src/hooks/useIsViewAsActive.ts
15
+ - src/stores/useOrderForZu.ts
12
16
  - src/components/OrderForDropdown/OrderForDropdown.tsx
13
17
  - src/components/OrderForDropdown/useOrderForDropdownViewModel.ts
14
- - src/stores/useOrderForZu.ts
15
- - src/components/Header/Header.tsx
16
- - src/pages/Cart/view/cartForm/EditCart.tsx
17
- - src/pages/Cart/CartPage.tsx
18
18
  - src/pages/Cart/api/CartApi.ts
19
+ - src/pages/Cart/viewModel/useCartViewModel.ts
20
+ - src/pages/Cart/CartPage.tsx
19
21
  - src/pages/Home/viewModel/FIELDS/COMPASS/ENGLISH/ADMIN/HEADERFIELDS.json
20
- - src/pages/Cart/viewModel/FIELDS/COMPASS/ENGLISH/ADMIN/CARTPAGE.ts
21
- - src/pages/Cart/types.ts
22
22
  - cypress/e2e/homePage/orderForDropdown.cy.ts
23
23
  related:
24
24
  - 2.0/apps/toga2-commerce/architecture.md
25
+ - 2.0/apps/toga2-commerce/features/view-as-session-tokens.md
25
26
  - 2.0/apps/toga2-commerce/features/client-fields.md
26
27
  - 2.0/apps/toga2-commerce/features/cart-notification-emails.md
27
28
  - 2.0/apps/toga2-commerce/features/cart-page-config-architecture.md
28
- - 2.0/apps/toga2-commerce/features/view-as-session-tokens.md
29
29
  - 2.0/apps/_underscore/features/order-for-delegation.md
30
30
  - 2.0/apps/toga2-commerce/workflows/cypress-testing.md
31
31
  ---
32
32
 
33
- Header pill that lets a buyer pick who they are ordering for from any page, and the persisted store that hands that pick to the cart's order-for cascade; open before touching the header pill, `useOrderForZu`, or the cart `orderFor` field.
33
+ Header pill that switches the session to a delegated token so a buyer orders **as** someone else; open before touching the pill, `useOrderForZu`, the delegation hooks, or the cart `orderFor` field.
34
34
 
35
35
  ## Summary
36
36
 
37
- TRUE-81851 (pill) + TRUE-81852 (persistence). Before this, "order for someone else" existed **only** on the cart form. The header now renders an **"Order for <Name>"** pill (next to the logo on desktop, inside the mobile `SlideMenu`) that sets the same pick from any page. The header does **not** run the order-for cascade — it stores the pick and the **cart stays the single owner** of the cascade.
37
+ TRUE-81851 (pill) + TRUE-81852 (persistence) + the 2026-09-22 token switch. Picking a person in the header **"Order for <Name>"** pill now mints that person's token pair and reloads the app as them — the same mechanism as [View As](view-as-session-tokens.md), with a different meaning (you may order; you are not read-only).
38
38
 
39
- **The pick is sticky.** Developer requirement: *"Order for needs to be persistent once chosen until the user changes it back to themself or logs out."* The store is persisted to localStorage and survives order submit, edit-order exit, view-as return, and page reload.
39
+ **Rejected: browser-side "catalog scoping".** Storing the pick and scoping the catalog in the browser was dropped. Only a real token switch makes api2's `Users_DelegatorUsers` gate, the target's catalog and prices, and `originalUserId` logging apply. Server side: [order-for delegation](../../_underscore/features/order-for-delegation.md).
40
40
 
41
- **Per-role, per-tenant gated.** Copy and the on/off switch live in `HEADERFIELDS.json` under an `orderFor` key, Compass USA only: `isEnabled` true for ADMIN / MANAGER / SUPERUSER, false for USER. Compass Canada and Quad have no key, so the component renders nothing (see gotchas).
41
+ **Per-role, per-tenant gated.** Copy and the on/off switch live in `HEADERFIELDS.json` under an `orderFor` key, Compass USA only: `isEnabled` true for ADMIN / MANAGER / SUPERUSER, false for USER. Compass Canada and Quad have no key, so the component renders nothing.
42
42
 
43
43
  ## How it works
44
44
 
45
- ### 1. The pill (`src/components/OrderForDropdown/`)
45
+ ### 1. The switch (`src/hooks/useOrderForDelegation.ts`)
46
46
 
47
- MVVM split: `OrderForDropdown.tsx` (view), `useOrderForDropdownViewModel.ts` (state + queries), `types.ts`, `index.ts`. Rendered from `Header.tsx`; hidden in edit-order mode.
47
+ On picking a person:
48
48
 
49
- - Trigger label: **"Order for Myself"** when ordering for yourself, else **"Order for <Name>"**.
50
- - Teal `#00B9A8` outline + inset glow on hover, when open, and when someone other than the signed-in user is picked (`data-active="yes"`). Icons are the inlined Figma SVG paths, not FontAwesome.
51
- - Menu: search box → yourself first as **"<Name> (Myself)"** → the user list → **Load more** past 100. Check mark on the current pick. `aria` listbox/option roles, Escape closes and returns focus to the trigger, outside click closes.
52
- - Copy keys under `orderFor`: `isEnabled`, `label`, `searchPlaceholder`, `myselfLabel`, `loadingMessage`, `noOptionsFoundMessage`, `loadMoreLabel` — in all four COMPASS/ENGLISH role files.
49
+ 1. `GET /users/encryptedUser?uuid=<target>` then `POST /auth/delegator` for the target's pair.
50
+ 2. `beginDelegation(pair, ownerUuid)` parks the owner's pair (`src/api/sessionTokens.ts`).
51
+ 3. `setDelegation({targetUuid, ownerUuid})` writes the Order For marker.
52
+ 4. Load the target: `fetchUser`, `fetchUserPageSettings`, `fetchUserPersonaData` (query key `userPersonaDataOnOrderFor`), then `setUser`.
53
+ 5. Clear the react-query cache, `localStorage["commerce"]` and the cart; `navigate("/")`; `window.location.reload()`.
53
54
 
54
- ### 2. The delegation list — `fetchDelegationList`, plus the legacy `GET /users` list
55
+ **"Myself"** restores the parked pair through the shared `useReturnToOriginalUser` hook (moved out of `AuthLayout` so both features use one copy).
55
56
 
56
- **Corrected 2026-09-21: a delegation table and API now exist** (TRUE-81848). The 2026-09-17 note here — "no delegation endpoint or table exists" — was true then and is **false now**: `Users_DelegatorUsers` + route `user-delegator-users` ship in every `Client_*` DB, and `/auth/delegator` now enforces the delegation, so it is no longer only the view-as token. Server side: [order-for delegation](../../_underscore/features/order-for-delegation.md).
57
+ The hook also **repairs a half-finished switch** on load (a reload landing between step 3 and step 4): restore the owner pair, or end the session. That repair reads the stores **live**, because the header mounts the hook twice (desktop + mobile) — a stale second copy would roll back again, find no parked pair, and log the user out.
57
58
 
58
- `fetchDelegationList(ownUuid)` in `src/pages/Cart/api/CartApi.ts` returns `DelegationOption[]` (`{uuid, name, email}`, `src/pages/Cart/types.ts`) — everyone that user may order for, one call, no paging. Three rules:
59
+ ### 2. The delegation list (`fetchDelegationList` in `src/pages/Cart/api/CartApi.ts`)
59
60
 
60
- - **Call it BEFORE any token switch.** Once the token belongs to the other person, the signed-in user's own record reads as a **403**.
61
- - **Never request `id` in the fields** — asking for `id` makes the whole GET 403 for these roles. Use `uuid`.
62
- - **It returns an empty list today** — `Users_DelegatorUsers` ships empty, with no admin screen and no import.
61
+ Reads `userDelegatorUsers.delegatorUser.{uuid,_name,email}` off `GET /users/{ownUuid}` at depth 5. Rules:
63
62
 
64
- The list the pill still renders is the same `GET /users` the cart uses, via `getUserOptions()` / `getSearchValuesFromAdvancedInput()` in `src/pages/Cart/api/CartApi.ts`:
63
+ - **Call it before the token switch** — on the target's token the owner's own record 403s.
64
+ - **Never request `id`** — it 403s the whole call. Use `uuid`.
65
+ - **No paging.** The "Load more" button and the `loadMoreLabel` copy key were removed; search filters the list in the browser.
66
+ - The list is snapshotted in `useOrderForZu.delegationOptions` so it survives on the target's token.
67
+ - `Users_DelegatorUsers` ships **empty**, so the pill shows only "Myself" until rows exist (8 rows seeded for Dev Team on client-sandbox, 2026-09-22).
65
68
 
66
- - **Admins** get all active users; server-side search when `CARTPAGE settings.isSearchAnyoneEnabled`.
67
- - **Supervisors** get their direct reports through the `Users@supervisor` join, filtered locally.
68
- - Field lists come from the per-tenant `CARTPAGE.ts`, read through `useAssignClientFields(fieldKey, language, user).fields.CARTPAGEFIELDS`.
69
+ ### 3. Telling Order For apart from View As (`src/stores/useOrderForZu.ts`)
69
70
 
70
- Hook queries: `useInfiniteQuery(["orderForDelegationList", user.uuid, fieldKey])` in pages of 100, and `useQuery(["orderForDelegationSearch", user.uuid, fieldKey, text])` debounced 400 ms with a 3-character minimum.
71
+ Both park the original user in `useReturnOriginalUserFromViewAsStore` and share **one** parked-token slot, so they cannot nest. Order For is identified by its own marker `delegation {targetUuid, ownerUuid}` in `useOrderForZu` (localStorage `order-for-zu`), separate from the cart's pick `orderForUser` — so clearing the cart never flips the mode.
71
72
 
72
- ### 3. `useOrderForZu` — persisted, owner-scoped
73
+ Helpers (store) and their hooks (`src/hooks/useIsViewAsActive.ts`):
73
74
 
74
- `src/stores/useOrderForZu.ts` uses Zustand `persist` with localStorage key **`order-for-zu`**. Stored shape `{ uuid, name, email, ownerUuid }`, where `ownerUuid` is the **signed-in user's** uuid at pick time. Picking yourself is stored too, so the cart knows to reset.
75
+ | Helper | Means |
76
+ |---|---|
77
+ | `isOrderForDelegationActive(delegation, currentUuid, originalUuid)` → `useIsOrderForDelegationActive` | running on the target's token |
78
+ | `isOrderForSwitchPending(delegation, currentUuid, originalUuid)` | marker written, user store still the owner — mid-switch |
79
+ | `useIsViewAsActive` | parked user present **and** not Order For active **and** not switch pending |
75
80
 
76
- - `getOrderForUserFor(pick, ownerUuid)` returns the pick **only** when `pick.ownerUuid === ownerUuid`. A different login, or a view-as target, never inherits it. A stale pick (ownerUuid mismatch) is cleared on cart mount.
77
- - **Cleared only by:** picking yourself in the header · the cart's clear-user action (`CartPage.handleClearUser` → `clearOrderForUser()`) · logout (`AuthContext.logout`).
78
- - **Kept across:** order submit (`useSubmitOrder.ts`, `createNewSalesOrderSubmit.ts`), edit-order exit (`exitEditOrderModeGlobalSyncReset.ts`), view-as return (`AuthLayout.returnToYourViewOnClick`), and full page reload. The clears that used to live in those four places were removed.
81
+ Consumers: `CartPage.tsx` (checkout gate `isViewAsOn`), `AuthLayout.tsx` (banner), `NavIconList.tsx` (the mount effect that clears the cart now runs for real View As only, so an Order For cart survives a refresh; starting View As also calls `endDelegation()`).
79
82
 
80
- ### 4. Header ↔ cart sync
83
+ ### 4. The cart while delegated
81
84
 
82
- The cart owns the cascade: `EditCart.tsx` watches the react-hook-form field `orderFor`, fetches the full user, then sets the sales-quote contact, manager name, cost center and notification emails, and stores the full user in `useSelectedUserZu`. The header must **not** duplicate that (~100 lines) and must **not** write `useSelectedUserZu` directly — the cascade needs the *previous* value and a *full* user object.
85
+ `src/pages/Cart/viewModel/useCartViewModel.ts` forces the `CARTPAGEFIELDS` `orderFor` input (`valueKey: "orderFor"`) to `isEditable: false` while delegated: the "User" field is read-only text holding the picked person — the same path basic users already take. `CartPage` re-seeds that read-only field with the current user after **Clear cart** (it otherwise went to `—` with no way to pick again; pre-existing for basic users). "Myself" leaves the input as the role config says.
83
86
 
84
- - `EditCart.tsx` applies the saved pick as **"Name (email)"** (the exact label shape the cart's own input produces) whenever the form's `orderFor` uuid differs from the stored pick. `appliedHeaderPickRef` is set **before** `setValue` and checked **both before and after** the mount seed's `await fetchUserByContactUuid(...)`, so the saved contact cannot overwrite a pick applied while that fetch is in flight.
85
- - The cart's user-load cascade writes back: `onSuccess` calls `setOrderForUser({ uuid, name: _name, email, ownerUuid: user.uuid })` so header and cart never drift.
86
- - `onError` calls `handleClearUser()` (required `EditCartTypes` prop passed from `CartPage.tsx`) and raises an error toast from the `CARTPAGE.ts` key `orderFor.settings.loadUserErrorMessage`, with the same string hardcoded as a fallback for tenants without the key.
87
- - Header display order: stored pick → `useSelectedUserZu.selectedUser` → self.
87
+ Scope decision: My Settings, My Shipping Addresses and Get Support stay read-only in both modes.
88
88
 
89
- Rejected: header writes `useSelectedUserZu` directly; header runs its own copy of the cascade.
89
+ The cart still owns the order-for cascade (contact, manager, cost center, notification emails) — see [cart-notification-emails](cart-notification-emails.md). The header must not duplicate it or write `useSelectedUserZu` directly.
90
90
 
91
- ### 5. e2e spec
91
+ ### 5. The pill
92
92
 
93
- `cypress/e2e/homePage/orderForDropdown.cy.ts` — 4 tests, Compass tenant, **4/4 green**. Asserts the pick survives a route change (via the `account-icon` router Link) and `cy.reload()`, and reads `localStorage['order-for-zu'].state.orderForUser.uuid` / `.ownerUuid` directly.
93
+ MVVM split under `src/components/OrderForDropdown/`, rendered from `Header.tsx`, hidden in edit-order mode; mobile copy inside `SlideMenu`.
94
94
 
95
- Test ids: `order-for-dropdown`, `order-for-trigger`, `order-for-current-name`, `order-for-menu`, `order-for-search`, `order-for-option`, `order-for-load-more` (mobile variants add `-mobile`).
95
+ - Label: **"Order for Myself"**, else **"Order for <Name>"**.
96
+ - Teal `#00B9A8` outline + inset glow **only on hover and while open**. Ordering for someone else is shown by the bold name and `data-active="yes"`, not a permanent highlight — a permanent one reads as "hover stuck". No design exists for a distinct active state.
97
+ - Menu: search, yourself first as **"<Name> (Myself)"**, then the list. Check mark on the current pick, `aria` listbox/option roles, Escape closes and returns focus, outside click closes.
98
+ - Copy keys under `orderFor`: `isEnabled`, `label`, `searchPlaceholder`, `myselfLabel`, `loadingMessage`, `noOptionsFoundMessage`.
99
+ - Test ids: `order-for-dropdown`, `order-for-trigger`, `order-for-current-name`, `order-for-menu`, `order-for-search`, `order-for-option` (mobile variants add `-mobile`).
100
+
101
+ ### 6. `useOrderForZu` — persisted, owner-scoped
102
+
103
+ Zustand `persist`, localStorage `order-for-zu`. Holds `orderForUser {uuid, name, email, ownerUuid}`, `delegation` and `delegationOptions`. Read the pick through `getOrderForUserFor(pick, ownerUuid)` — it returns the pick only when `pick.ownerUuid` matches the signed-in user and the uuid is a real uuid. `clearOrderForUser()` drops the cart-facing pick only; `endDelegation()` drops everything (return to self, logout, View As start).
96
104
 
97
105
  ## Gotchas
98
106
 
99
- - **⚠ The pick alone does NOT order for anyone — the front end must switch the token.** Today the pill only stores the pick and passes it as a cart form value; nothing calls `/auth/delegator`. So `$api->delegator` is never set, `originalUserId` stays NULL, the server-side delegation gate never fires, and the buyer browses their **own** catalog and prices while "ordering for" someone else. Required switch: `GET /users/encryptedUser?uuid=<target>` → `POST /auth/delegator` → `beginDelegation(tokens, originalUserUuid)` from `src/api/sessionTokens.ts`; `restoreOriginalTokens()` returns with no new login. **One parked-token slot, shared with View As** — Order For and View As cannot nest. Owned by another developer; not in TRUE-81848.
100
- - **The `ownerUuid` guard is what makes persistence safe — never drop it.** A persisted pick with no owner check is inherited by the next login or by a view-as target and silently orders for the wrong person. Read the store through `getOrderForUserFor(pick, ownerUuid)`, never raw.
101
- - **Do not set `useSelectedUserZu` from the header.** The cascade reads the *previous* selected user out of the store (see [cart-notification-emails](cart-notification-emails.md)); writing it early makes the previous-user email cleanup a no-op and strands the old person's notification emails on the order.
102
- - **A missing `orderFor` key renders nothing, by design.** `useAssignClientFields` has no fallback for a missing key, so Compass Canada and Quad get `undefined` and the component returns `null` — safe default-off. Adding a tenant means adding the key to every role file for that tenant.
103
- - **`appliedHeaderPickRef` must be checked after the awaited mount fetch too.** Checking only before the `await fetchUserByContactUuid(...)` lets the saved contact overwrite a pick made while the fetch was in flight.
104
- - **`_production` and `_sandbox-client` have diverged in `EditCart.tsx`** — `_production` lacks the sandbox delegate-manager code, so a stashed patch will not apply cleanly to that file. To move uncommitted work between branches: `git diff stash@{0}^1 stash@{0} > patch`, dry-run with `git apply --check --3way patch`, then `git apply --3way --exclude=<conflicting file> patch` and hand-port the conflicting file. Keep the stash until the work is committed.
105
- - **Tailwind: `max-h-300px` is not a token** — use `max-h-[300px]`. `w-345px`, `h-300px`, `border-1` and `shadow-subMenu` do exist.
107
+ - **Never test "View As is on" with `Object.keys(returnOriginalUser).length > 0`.** Order For parks the same user, so that test reports View As and blocks checkout. Use `useIsViewAsActive`.
108
+ - **Order For and View As cannot nest** — one `originalAuthTokens` slot, and `beginDelegation` refuses to overwrite it.
109
+ - **The marker is written before the target is loaded**, so there is a gap where the parked user exists but the user store is still the owner. That gap flashed the View As banner; `isOrderForSwitchPending` covers it. Any new "am I delegated?" check must handle it.
110
+ - **Order of operations on return matters.** In `useReturnToOriginalUser`, `setUser(owner)` then `clearOriginalUser` then `endDelegation` must run right after `restoreOriginalTokens` and **before any await** (token refresh, refetch). Otherwise an expired parked token's refresh opens a window where the "parked pair gone while delegated" guard logs the user out, and the banner / cart-clear flicker.
111
+ - **Read the stores live in the repair path.** The header mounts `useOrderForDelegation` twice (desktop + mobile); a captured snapshot makes the second copy roll back a switch that already finished and log the user out.
112
+ - **The `ownerUuid` guard is what makes persistence safe — never drop it.** A pick with no owner check is inherited by the next login or by a delegated target.
113
+ - **A missing `orderFor` key renders nothing, by design** (`useAssignClientFields` has no fallback). Adding a tenant means adding the key to every role file for that tenant.
114
+ - **Delegation failures are silent.** `onError` only logs to Sentry — no toast (developer's call). A refused pick looks like nothing happened.
115
+ - **`_production` and `_sandbox-client` have diverged in `EditCart.tsx`**, so a stashed patch will not apply cleanly. Move work with `git diff stash@{0}^1 stash@{0} > patch`, `git apply --check --3way patch`, then `git apply --3way --exclude=<conflicting file> patch` and hand-port the rest.
116
+ - **Tailwind: `max-h-300px` is not a token** — use `max-h-[300px]`.
117
+ - **`.env.development` points at `https://api.beta.togahub.com/v2` by default.** client-sandbox is `https://api.client.sandbox.togahub.com/v2` — check the host before concluding a call was never made.
118
+
119
+ ## Open items
120
+
121
+ - `SalesOrders.c_orderSubmittedById` is still never set. It belongs in `_Model_Compass_SalesOrder::prePost()` from `$api->delegator->user`, not in the client. The earlier client-side write was dropped.
122
+ - `Logs_Compass.Api.originalUserId` was **NULL** for every row of an Order For session on client-sandbox (2026-09-22, userId 303631, including `POST /v2/sales-orders`) — the delegator is not being stamped there. See [order-for delegation](../../_underscore/features/order-for-delegation.md).
106
123
 
107
124
  ## Change history
108
125
 
109
- - 2026-09-21 — Corrected the "no delegation API exists" finding: `Users_DelegatorUsers`, route `user-delegator-users` and the enforced `/auth/delegator` now exist (TRUE-81848). Added `fetchDelegationList` + `DelegationOption` (call before any token switch; never request `id`), and recorded that without the token switch the pick changes nothing server side. (bala)
110
- - 2026-09-17 — Pick is now persisted to `localStorage["order-for-zu"]` with an `ownerUuid` guard (TRUE-81852); the submit / edit-order-exit / view-as-return / reload clears were removed, and the cart writes the loaded user back to the store. Spec now 4/4 green. (tcox)
111
- - 2026-09-17 — Header order-for pill added (TRUE-81851), in-memory hand-off store, cart clears on failed user load. (tcox)
126
+ - 2026-09-22 — Order For now switches the token (encryptedUser, /auth/delegator, beginDelegation, reload) instead of scoping the catalog in the browser; added the `delegation` marker plus `useIsViewAsActive` / `useIsOrderForDelegationActive`, the read-only cart `orderFor` field while delegated, the shared `useReturnToOriginalUser`, and the no-paging delegation list. Fixed the View As banner flash mid-switch and the return-flow ordering. Pill highlight is hover/open only. (tcox)
127
+ - 2026-09-21 — Corrected the "no delegation API exists" finding: `Users_DelegatorUsers`, route `user-delegator-users` and the enforced `/auth/delegator` now exist (TRUE-81848); added `fetchDelegationList`. (bala)
128
+ - 2026-09-17 — Pick persisted to `order-for-zu` with an `ownerUuid` guard (TRUE-81852); submit / edit-order-exit / view-as-return / reload clears removed. (tcox)
129
+ - 2026-09-17 — Header order-for pill added (TRUE-81851). (tcox)
112
130
 
113
131
  ## Related
114
132
 
133
+ - [view-as-session-tokens](view-as-session-tokens.md)
115
134
  - [architecture](../architecture.md)
116
135
  - [client-fields](client-fields.md)
117
136
  - [cart-notification-emails](cart-notification-emails.md)
118
137
  - [cart-page-config-architecture](cart-page-config-architecture.md)
119
- - [view-as-session-tokens](view-as-session-tokens.md)
120
138
  - [order-for delegation (server side)](../../_underscore/features/order-for-delegation.md)
121
139
  - [cypress-testing](../workflows/cypress-testing.md)
@@ -13,6 +13,8 @@ files:
13
13
  - src/api/apiBaseUrl.ts
14
14
  - src/api/axiosInstance.ts
15
15
  - src/contexts/AuthContext.tsx
16
+ - src/hooks/useReturnToOriginalUser.ts
17
+ - src/hooks/useIsViewAsActive.ts
16
18
  - src/components/AuthLayout/AuthLayout.tsx
17
19
  - src/components/NavIcons/NavIconList.tsx
18
20
  - cypress/e2e/viewAs/viewAsUser.cy.ts
@@ -40,8 +42,15 @@ All token plumbing lives in **`src/api/sessionTokens.ts`** (new). `src/api/apiBa
40
42
  2. `beginDelegation(delegatedPair, adminUuid)` parks the admin's current pair under localStorage key **`originalAuthTokens`** as `{ v: 1, access, refresh, userUuid, apiHost, savedAt }`, then writes the delegated pair into `accessToken` / `refreshToken`.
41
43
  3. It **never overwrites an existing parked pair** — view-as is one level deep. `isViewAsEnabled` also hides the button when `hasSavedOriginalTokens()` is true.
42
44
  4. On any error entering view-as, it rolls back: `restoreOriginalTokens` if a pair is parked, plus `clearOriginalUser()`.
45
+ 5. It also calls `endDelegation()` so an old Order For marker cannot make the new session read as Order For, and its mount effect that clears the cart is gated on `useIsViewAsActive` — otherwise it wiped an Order For cart on every load.
43
46
 
44
- ### Returning (`AuthLayout.returnToYourViewOnClick`)
47
+ ### Which mode is running — `useIsViewAsActive`
48
+
49
+ "Order For" parks the **same** original user in the **same** slot, so a parked user alone no longer means View As. Test the mode with `useIsViewAsActive` / `useIsOrderForDelegationActive` (`src/hooks/useIsViewAsActive.ts`), never by inspecting `returnOriginalUser` directly. See [header-order-for-dropdown](header-order-for-dropdown.md).
50
+
51
+ ### Returning (shared hook `src/hooks/useReturnToOriginalUser.ts`)
52
+
53
+ Moved out of `AuthLayout` so View As and Order For return through one copy. `setUser(owner)`, `clearOriginalUser` and `endDelegation` run **immediately after** `restoreOriginalTokens` and **before any await**.
45
54
 
46
55
  `restoreOriginalTokens(adminUuid)`:
47
56
 
@@ -75,17 +84,21 @@ Seeds a long-lived `ADMIN_ACCESS_JWT`; stubs `/auth/delegator` → `mock-delegat
75
84
  - **Clear `localStorage["commerce"]` on return.** The persisted query cache is shared; without the clear, the impersonated user's data rehydrates under the admin identity (see the 24h persisted-cache gotcha in [architecture](../architecture.md)).
76
85
  - **Keep the refresh identity-bound.** A refresh that resolves after the identity changed must return `null`, not write tokens. Bump the epoch on every identity change, including the view-as swap.
77
86
  - **⚠ Optional-chain the whole token path on the swap.** `response?.data.tokens.access` wrote the literal string `"undefined"` on a malformed reply — truthy, so every later call sent `Bearer undefined`.
87
+ - **Never test "View As is on" with `Object.keys(returnOriginalUser).length > 0`.** Order For parks the same user; use `useIsViewAsActive`.
88
+ - **Clear the delegated state before any await on return.** `setUser(owner)` / `clearOriginalUser` / `endDelegation` must land before the token refresh or any refetch, or an expired parked token's refresh opens a window where the "parked pair gone while delegated" guard logs the user out (and the banner and cart-clear flicker).
89
+ - **⚠ Admin View As is now gated by the Order For delegation list.** `canUserOrderForOtherUser` guards `/users/encryptedUser` and `/auth/delegator` for everyone, so View As returns **403 EZ-1** for any target not on the signed-in user's `Users_DelegatorUsers` rows (seen on api.beta 2026-09-22 for user `50160ccc`). The UI shows nothing — `onError` only logs to Sentry. On prod, View As works only for listed people.
78
90
  - **A view-as test must stub `/v2/pages/meta?page=home-page&app=commerce`.** Unstubbed, the real call fails and triggers the enter-view-as rollback, so the spec sees a return-to-admin it did not ask for.
79
91
 
80
92
  ## Open items (out of ticket, not fixed)
81
93
 
82
94
  - `SENTRY_AUTH_TOKEN` is committed in 25 tracked `.env*` files — treat it as compromised and rotate it. (Never store the value in this KB.)
83
95
  - ~~Confirm api2 authorizes `/auth/delegator` **server-side**~~ — **answered 2026-09-21:** it now re-checks `_Model_Client_User::canUserOrderForOtherUser` at mint time (and `/auth/refresh` re-checks it), so a captured encrypted pair cannot be replayed. See [order-for delegation](../../_underscore/features/order-for-delegation.md).
84
- - **The parked-token slot is shared with "Order For".** There is one `originalAuthTokens` key and `beginDelegation` refuses to overwrite it, so once Order For switches tokens the two features cannot nest — decide the precedence before wiring the Order For switch.
96
+ - ~~The parked-token slot is shared with "Order For" — decide precedence~~ — **settled 2026-09-22:** one slot, no nesting; the mode is told apart by the Order For marker, not the slot.
85
97
 
86
98
  ## Change history
87
99
 
88
100
  - 2026-09-22 — Refresh identity binding upgraded to an epoch bumped on the view-as swap; `Bearer undefined` swap bug fixed. TRUE-81991 (`e546db3d`). (apeterson)
101
+ - 2026-09-22 — Return moved to the shared `useReturnToOriginalUser` hook (state cleared before any await); mode now tested with `useIsViewAsActive` because Order For parks the same user; View As start calls `endDelegation()` and its cart-clear effect is gated; recorded the 403 EZ-1 when the target is not on the delegation list. (tcox)
89
102
  - 2026-09-21 — `/auth/delegator` is now gated server side by the Order For delegation check; noted that Order For will share the single parked-token slot. (bala)
90
103
  - 2026-09-17 — Created. Admin pair parked in `originalAuthTokens` on delegation and restored on return; `/auth/encrypted` re-mint removed; single-flight identity-bound refresh (TRUE-81852, commit a8c3a537). (tcox)
91
104
 
@@ -6,7 +6,7 @@ project: TOGa Commerce
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-09-17
9
+ updated: 2026-09-22
10
10
  owners: [tcox]
11
11
  files:
12
12
  - toga2-commerce/cypress/e2e/cartPage/cartV2.cy.ts
@@ -101,7 +101,14 @@ The cart UI was instrumented with `data-testid` attributes so specs anchor on **
101
101
 
102
102
  ### Other active specs
103
103
 
104
- - `cypress/e2e/homePage/orderForDropdown.cy.ts` — the header order-for pill, COMPASS only, 4 tests, **4/4 green**. Asserts the pick survives a route change and `cy.reload()` by reading `localStorage['order-for-zu']`. Ids `order-for-*` (see [header-order-for-dropdown](../features/header-order-for-dropdown.md)).
104
+ - `cypress/e2e/homePage/orderForDropdown.cy.ts` — the header order-for pill, COMPASS only, **5 tests**. Now covers the delegated **token switch**: stubs `users/{admin}?*userDelegatorUsers*`, `encryptedUser`, `POST auth/delegator`, the target-user fetch by regex (`delay: 1500`), `pages/meta` and bundles. Ids `order-for-*` (see [header-order-for-dropdown](../features/header-order-for-dropdown.md)).
105
+
106
+ **Testing a token switch — four rules learned here:**
107
+
108
+ 1. **Do not `cy.reload()` right after the swap** — the app reloads itself and the two race. Wait until `localStorage['zu-user']` holds the picked person, then reload.
109
+ 2. **To prove something never appears mid-switch, slow the stub** (`delay`) and check **once** with `cy.document()` — `should('not.exist')` retries until it becomes true and passes on a state that did flash.
110
+ 3. **Stub a refusal as `403`**, api2's real UNAUTHORIZED code. A `401` triggers the axios refresh interceptor instead and tests the wrong path.
111
+ 4. **A catch-all `cy.intercept` with `req.continue()` must be defined BEFORE the login command's stubs** in `beforeEach` — later intercepts win, so a catch-all defined after steals them.
105
112
  - `cypress/e2e/viewAs/viewAsUser.cy.ts` — admin view-as enter/return, **1/1 green**. Seeds a long-lived admin JWT, stubs `/auth/delegator` and `**/v2/pages/meta?page=home-page&app=commerce*` (see [view-as-session-tokens](../features/view-as-session-tokens.md)).
106
113
 
107
114
  ### Known-red specs (pre-existing, not yours)
@@ -132,10 +139,13 @@ On prod base `aedd1f7a`, Compass tenant: **13 failures** — `getSupportV2` (4),
132
139
  - **`BaseButton` never sets the native `disabled` attribute (test-critical).** `src/components/BaseButton/BaseButton.tsx` sets `aria-disabled="true"|"false"` and suppresses `onClick` inside `handleClick` — it deliberately leaves the element enabled so a disabled button (e.g. **Proceed to Checkout**) still receives mouse events and its `BaseTooltip` (*"Please complete all required fields to proceed."*) works. Cypress assertions **MUST** use `.should('have.attr', 'aria-disabled', 'true'|'false')`, **never** `.should('be.disabled')` — `be.disabled` times out even though the button is visually/functionally disabled. (This cost the only 2 first-run failures.)
133
140
  - **Seed → then `cy.visit('/cart')`.** The persist stores rehydrate on load only; seeding `localStorage` and navigating in-app leaves the stores stale (see seeding section).
134
141
  - **Bundle seeds are on the legacy shape** — do not write a bundle spec against them until they are rebuilt to the `bundleZuCartUuid` + `bundleProgressContents` contract.
135
- - **ESLint fails repo-wide in a fresh checkout** because `.eslintrc.cjs` references `eslint-plugin-react-compiler`, which is not installed; `npm install --save-dev eslint-plugin-react-compiler` fixes it. To lint without touching the checkout, run `npx eslint --no-eslintrc --plugin react-hooks --plugin @typescript-eslint <files>`. This is a local-environment issue, not a spec problem.
142
+ - **ESLint fails repo-wide in a fresh checkout** because `.eslintrc.cjs` references `eslint-plugin-react-compiler`, which is not in `package.json`, so `npm run lint` fails on a clean install. Check changed files with `tsc` plus `npx eslint --no-eslintrc --plugin react-hooks --plugin @typescript-eslint <files>`. This is a local-environment issue, not a spec problem.
143
+ - **`npm install` rewrites `package-lock.json` on a newer npm** (e.g. tanstack 5.101.4 → 5.103.2). Do not ship the lock file in a feature commit unless the bump is intended.
144
+ - **Never start a second Vite dev server in a repo someone else is running.** It corrupts `node_modules/.vite` and breaks their browser. Recovery: stop every server, delete `node_modules/.vite`, restart one.
136
145
 
137
146
  ## Change history
138
147
 
148
+ - 2026-09-22 — `orderForDropdown.cy.ts` now covers the delegated token switch (5 tests); added the four token-switch testing rules (reload race, non-retrying "never appears" check, 403 not 401, catch-all intercept ordering) plus the package-lock drift and second-Vite-server warnings. (tcox)
139
149
  - 2026-09-17 — Added `viewAsUser.cy.ts`; orderForDropdown now green; recorded the `ELECTRON_RUN_AS_NODE` failure, the intercept pitfalls, and the 13 pre-existing red specs on base `aedd1f7a`. (tcox)
140
150
  - 2026-09-17 — Created with the V2 convention set and the `cartV2.cy.ts` cart harness (slice 1). (tcox)
141
151
 
@@ -4,7 +4,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
4
4
 
5
5
  ## 1.0 framework
6
6
 
7
- - **library** (Library) _(framework core)_ — 26 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
7
+ - **library** (Library) _(framework core)_ — 27 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
8
  - **worker** (Worker) — 38 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
9
9
  - **dbchanges** (Database Changes) _(framework core)_ — 1 doc(s) → [1.0/apps/dbchanges/INDEX.md](1.0/apps/dbchanges/INDEX.md)
10
10
  - **worker1.5** (Worker 1.5) — 0 doc(s) → [1.0/apps/worker1.5/INDEX.md](1.0/apps/worker1.5/INDEX.md)
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: compass-usa
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-09-22
10
10
  owners: ["rgirish", "bala", "dfranks", "jcardinal", "mhammontree"]
11
11
  files:
12
12
  - worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php
@@ -18,6 +18,7 @@ files:
18
18
  - worker/schedules/cron.worker.sync.json
19
19
  - library/app/client/compass.php
20
20
  related:
21
+ - 1.0/apps/library/features/db-connection-lifecycle-and-reconnect.md
21
22
  - 1.0/apps/worker/features/netsuite-sales-order-sales-rep-sourcing.md
22
23
  - clients/compass-usa/features/odp-edi-file-retention-and-retry.md
23
24
  - clients/compass-usa/features/odp-edi-850-item-resolution.md
@@ -138,6 +139,13 @@ The NetSuite writeback lands on the **downstream ODP SalesOrder (customerId=1)**
138
139
  - **⚠ Do NOT expect a 30-minute wait — branch (a) is normal.** If you assume half an hour is normal, a genuinely stalled transmission looks fine for 30 minutes. An order that has all its 850s back and is still untransmitted **two `*/10` runs later** is already abnormal — go look at the bridge rows (`PurchaseOrders_SalesOrders` / `SalesOrders_PurchaseOrders`) rather than waiting. The 30-minute timeout is the exception, reserved for orders where something never comes back.
139
140
  - **⚠ The 30-minute clock runs from the Office Depot PO's `dtCreated`** — the moment we imported the 850, not when the Compass order was raised. So re-importing an old EDI file **restarts the timer**, and a recovered order waits another 30 minutes.
140
141
  - **⚠ A late 850 produces a SECOND NetSuite sales order.** If a PO's 850 arrives *after* the Compass SO has already transmitted, the new ODP SO is picked up on its own and creates a **separate** NetSuite SO rather than joining the first.
142
+ - **⚠ Duplicate NetSuite SOs with NO duplicate in Toga = the dead-connection stamp race (root-caused 2026-09-22, FIXED in `worker` `afce32ca`).** Symptom: Compass PO `42031181-1127` produced **three** NetSuite SOs (290875/290876/290877), each exactly **10 minutes** apart — 15 duplicate NetSuite SOs across 11 Compass POs on Sep 21-22. **`Client_Compass.PurchaseOrders` and `SalesOrders` each had exactly ONE row.** The duplication is only in NetSuite, so counting Toga rows will tell you nothing is wrong.
143
+ - **Mechanism.** Cron 5 holds one MySQL connection across the slow NetSuite SOAP `add()`. `wait_timeout` on the prod client cluster is **180 seconds**. A kit order's `add()` runs past that, so the connection is dead when the stamp `UPDATE SalesOrders SET c_dtTransmittedToNetsuite ...` runs (`crons/.../5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php:433`). The order exists in NetSuite but is **unstamped**, so the next `*/10` run re-sends it.
144
+ - **Proof, not inference.** `Logs.Issue` **769** (reference `W0`), *"mysqli_select_db(): MySQL server has gone away"*, first seen 2026-09-15, 33 occurrences; trace `query.php:168 → database.php:431 → cron 5 :433`. **All 15** duplicate NetSuite SOs are followed by a gone-away event 8-10 s later, zero misses; the 10 SOs that stuck have no matching error. **Logs are CDT, NetSuite is EST — apply the 1-hour offset before comparing timestamps.**
145
+ - **Why kits.** `PC-KIT-OPT2` (10 expanded lines, all $12.58) was 10 of the 11 duplicated POs. The part number does not cause this — kits are the slow path, so they are the orders whose connection times out.
146
+ - **Fix (deployed in cron 5).** (a) `App_Database::keepAlive($clientToga2DatabaseLink)` before `$service->add()` and again after it returns (outside the try/catch, guarded on `!is_null($response)`) — the pattern sibling script `3b_import_strategic_systems_advance_shipping_notices.php` already had at `:616`/`:1399`/`:1411` and cron 5 never did. (b) A `register_shutdown_function()` that emails devteam when the process dies with an order created in NetSuite but unstamped in Toga. Full mechanics: [1.0 MySQL Connection Lifecycle](../../../1.0/apps/library/features/db-connection-lifecycle-and-reconnect.md).
147
+ - **⚠ The try/catch cron 5 added on Sep 21 to prevent exactly this is DEAD CODE.** Commit `fe4d110d` wrapped the stamp in a `try`/`catch` specifically to stop duplicates. It cannot fire: 1.0 turns the "gone away" **warning** into a fatal `exit` inside the error handler, so it never unwinds to a `catch`. Evidence: 33 `Logs.Issue` 769 occurrences, **zero** matching rows in `Logs_Compass.Email`. Only the shutdown handler runs after that `exit`.
148
+ - **⚠ The Sep 21 push is what made a latent bug visible.** `fe4d110d` flipped this cron from `"active": 0` to `"active": 1` in `schedules/cron.worker.sync.json`, and `85eb8784` turned it into a 450-second bounded loop (`$startTime = time() + 450`) running every 10 minutes. The stamp race had existed since 2026-09-15; the job was simply off. Two days of running produced 15 duplicates. **Before re-enabling any long-dormant cron that writes to an external system, check its stamp path.**
141
149
  - **⚠ The ODP PO number lands in `otherRefNum`, which NetSuite global search does NOT index.** Cron 5 sets `$salesOrder->otherRefNum = substr($rowSalesOrders['PurchaseOrderNumbers'], 0, 45)` (`:194`). Typing the Office Depot PO number into NetSuite's **global search box returns nothing** — the field is populated, it is simply not searchable. That silence reads exactly like "the order never reached NetSuite," and it is not. **Open the sales order by internal id instead:** take `c_netsuiteInternalSalesOrderId` from the **Office Depot** `SalesOrders` row (`customerId = 1`). Verified 2026-08-31 on ODP POs `41849836-1079` / `41849745-1127` / `41849655-5125` → SO internal ids `7437998` / `7437898` / `7438098`.
142
150
  - **⚠ Do not infer the schedule from transmission times.** Some transmissions cluster at **:00 and :30**, which reads like a 30-minute cron — it is not. The schedule is **`*/10`** (re-verified in `worker/schedules/cron.worker.sync.json` 2026-08-28); that clustering is the timeout branch of the release gate firing on the orders that can only release that way. Orders released by branch (a) do **not** cluster — they land on whichever `*/10` run follows their last 850 (e.g. `13:20:46`).
143
151
  - **⚠ Office Depot only sends an 850 back for what they buy FROM us (confirmed with ODP 2026-08-28).** A single Compass sales order can carry **two different kinds** of Compass PO, and **both** have `vendorId` = Office Depot:
@@ -149,7 +157,11 @@ The NetSuite writeback lands on the **downstream ODP SalesOrder (customerId=1)**
149
157
  - **⚠ `PurchaseOrderNotes` is NOT the record of truth.** That table is **empty across ALL POs** — do not read its emptiness as evidence a transmission script never ran. (Transmission evidence is `dtSubmitted` + the `Logs_Compass.Record` "transmitted to Vendor" note — see the MITS PO transmission feature.)
150
158
  - **Editing cron recipient lists — a grep hit is not a live hit.** Email recipients are hardcoded in the cron scripts, and **several dormant copies of the same script exist** under `crons/toga2/compass/edi/` and `crons/toga2/compass/DOA_edi_odp_flow/`. Before changing a recipient list, cross-check the file against `worker/schedules/*.json` for a referencing entry whose `active` is not `0`. Only the scheduled copies matter.
151
159
 
160
+ ## Change history
161
+ - 2026-09-22 — Root-caused the duplicate NetSuite SOs (15 across 11 POs, Sep 21-22) as the dead-MySQL-connection stamp race, not a Toga duplicate; fixed cron 5 with `keepAlive` around `add()` + a `register_shutdown_function` alert (`worker` `afce32ca`). Recorded that the Sep 21 try/catch is dead code and that `fe4d110d` re-enabling the cron is what surfaced the latent bug. (rgirish)
162
+
152
163
  ## Related
164
+ - [1.0 MySQL Connection Lifecycle — keepAlive / reconnect](../../../1.0/apps/library/features/db-connection-lifecycle-and-reconnect.md)
153
165
  - [Compass Order Lifecycle & Data-Integrity Invariants](order-lifecycle-and-data-integrity.md)
154
166
  - [Compass MITS PO Transmission to Vendors](../features/mits-po-transmission-to-vendors.md)
155
167
  - [Compass MITS PO → SO Item Linking](../features/mits-po-to-so-item-linking.md)
@@ -14,3 +14,4 @@
14
14
  | [Rate Tech-Support Subscription Cancellation (portal self-service)](features/subscription-cancellation.md) | 2.0 | Portal self-service cancellation of a Rate tech-support subscription (cancel at PayPal, wind down to period end); open before touching the cancel flow, the two- |
15
15
  | [Rate Whole Home Warranty Per-Address Purchase Guard](features/whole-home-warranty-purchase-guard.md) | 2.0 | The `prePost` guard enforcing one active Whole Home Warranty per validated address (global), via USPS DPV + unit-level dedup; open before touching the WH guard, |
16
16
  | [Rate](profile.md) | 2.0 | Rate client profile (mortgage/lending; home warranty + home tech support on TOGa View, SAML SSO); open when starting any Rate work. |
17
+ | [Rate — Annual SSL Certificate Renewal (4 Home Warranty / Home Tech Support domains)](workflows/annual-ssl-certificate-renewal.md) | 2.0 | How to generate the four Rate CSRs each year, what to send the GR System team, and what never to send; open when Rate's certs are near expiry. |
@@ -27,6 +27,7 @@ related:
27
27
  - clients/rate/features/paypal-subscription-purchase-webhook.md
28
28
  - clients/rate/features/subscription-cancellation.md
29
29
  - clients/rate/features/home-warranty-terms-content.md
30
+ - clients/rate/workflows/annual-ssl-certificate-renewal.md
30
31
  ---
31
32
  Rate client profile (mortgage/lending; home warranty + home tech support on TOGa View, SAML SSO); open when starting any Rate work.
32
33
 
@@ -0,0 +1,85 @@
1
+ ---
2
+ title: Rate — Annual SSL Certificate Renewal (4 Home Warranty / Home Tech Support domains)
3
+ framework: "2.0"
4
+ project: SAML SSO Gateway
5
+ client: rate
6
+ type: workflow
7
+ status: active
8
+ updated: 2026-09-22
9
+ owners: ["rgirish"]
10
+ files: []
11
+ related:
12
+ - ../profile.md
13
+ - ../../../2.0/apps/_underscore/workflows/rotating-public-tls-certificates.md
14
+ - ../../../2.0/standards/ssl-certificate-trust-model.md
15
+ ---
16
+ How to generate the four Rate CSRs each year, what to send the GR System team, and what never to send; open when Rate's certs are near expiry.
17
+
18
+ ## Summary
19
+ Rate's four public domains do **not** use auto-renewing ACM certs. Rate's own **GR System team** issues the certs, so every year we generate CSRs and hand them over. This repeats annually and the procedure was tribal knowledge — it only lived in a Dropbox folder with no written steps.
20
+
21
+ The four domains:
22
+ - `homewarranty.rate.com`
23
+ - `homewarranty-test.rate.com`
24
+ - `hometechsupport.rate.com`
25
+ - `hometechsupport-test.rate.com`
26
+
27
+ **The three things that bite you:**
28
+ 1. **Never send the private key.** Only the four `.csr` files go to the GR System team. Sending the key compromises the cert and forces a reissue.
29
+ 2. **Keep last year's private key** until the new certs are live — the certs in production right now still use it.
30
+ 3. **A CloudFront cert must be imported into ACM in `us-east-1`**, no matter where the rest of the stack lives. CloudFront cannot read a cert from any other region. These certs serve CDN (CloudFront) and are linked to AWS Amplify.
31
+
32
+ Artifacts live in Dropbox under **`Dev Team / SSL Certificates / RATE.COM / <date>`** — one dated folder per renewal. The key material lives only there; never copy it into a repo or a doc.
33
+
34
+ ## Steps
35
+
36
+ ### 1. Copy last year's folder as the starting point
37
+ Each domain has its own `openssl` `.conf` file with a **single SAN entry matching its CN**. The conf contents are stable year over year — confirmed unchanged between the 2025 and 2026 renewals, so reuse them.
38
+
39
+ Subject fields (identical across all four, only CN differs):
40
+
41
+ | Field | Value |
42
+ |---|---|
43
+ | C | US |
44
+ | ST | IL |
45
+ | L | Chicago |
46
+ | O | Guaranteed Rate Inc. |
47
+ | OU | IT |
48
+ | emailAddress | grsystemsteam@rate.com |
49
+
50
+ Key size 2048, signature `sha256`.
51
+
52
+ ### 2. Generate a fresh private key
53
+ Generate a **new** private key for the renewal rather than reusing last year's. One shared key covers all four CSRs — that is the established pattern from the prior year, and it was kept.
54
+
55
+ ### 3. Generate the four CSRs
56
+ One CSR per domain, each using its own `.conf` and the shared new key.
57
+
58
+ ### 4. Verify every CSR before sending
59
+ Do not skip this — a wrong CN or a CSR that does not match the key means a wasted round trip with Rate. For each of the four, confirm:
60
+ - subject CN is the right domain,
61
+ - the SAN matches the CN,
62
+ - signature algorithm is sha256,
63
+ - key size is 2048,
64
+ - the CSR self-signature verifies,
65
+ - the **CSR modulus matches the private key modulus**.
66
+
67
+ All four passed on the 2026-09-22 renewal.
68
+
69
+ ### 5. Send only the CSRs
70
+ Send the GR System team the **four `.csr` files and nothing else**.
71
+ - The `.conf` files are our inputs — they do not need them.
72
+ - The private key is **never** sent.
73
+
74
+ ### 6. Install the issued certs
75
+ When the certs come back, import into **ACM in `us-east-1`** for the CloudFront/Amplify path. Only after the new certs are confirmed live should last year's private key be retired.
76
+
77
+ ## Gotchas
78
+ - **The private key never leaves us.** Only `.csr` files go out. This is the single highest-cost mistake here.
79
+ - **Do not delete the previous year's key early.** Production certs still use it until the new ones are live.
80
+ - **`us-east-1` or CloudFront cannot see the cert** — regardless of where the rest of the stack runs. Same rule as the shared [TLS rotation runbook](../../../2.0/apps/_underscore/workflows/rotating-public-tls-certificates.md).
81
+ - **These are partner-issued, not ACM auto-renewing.** They will not renew themselves — this runbook runs every year. The general TOGa preference for Amazon-issued DNS-validated certs (see the [trust-model standard](../../../2.0/standards/ssl-certificate-trust-model.md)) does not apply while Rate's own team issues them.
82
+ - **Confirm the conf files before assuming they are unchanged.** They were identical 2025 → 2026, but diff them rather than trusting it.
83
+
84
+ ## Change history
85
+ - 2026-09-22 — Created from the 2026 renewal. Generated a fresh private key (rather than reusing the 2025 key/CSRs) and four new CSRs; all four verified on CN, SAN, sha256, 2048-bit, self-signature and key-modulus match. (rgirish)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.871",
3
+ "version": "1.0.873",
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",