toga-ai 1.0.623 → 1.0.624

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-13
9
+ updated: 2026-08-20
10
10
  owners: [bala]
11
11
  files:
12
12
  - _underscore/Model/Client/Logs/Record.php
@@ -15,6 +15,9 @@ files:
15
15
  related:
16
16
  - ../../api2/features/request-logging.md
17
17
  - ../../../../clients/compass-usa/features/approval-decision-flow.md
18
+ - ../../../../clients/compass-usa/features/stranded-approval-reassignment.md
19
+ - ../../api2/features/api-payload-interceptors.md
20
+ - ../../worker2/features/creating-worker-actions.md
18
21
  ---
19
22
 
20
23
  ## Summary
@@ -90,6 +93,19 @@ single record: the milestone notes are the chapter headings, the field changes a
90
93
  automatically. Real example:
91
94
  [Compass PEOPLE-file user lifecycle](../../../../clients/compass-usa/features/people-file-user-lifecycle.md),
92
95
  whose deactivation batch is a raw `UPDATE Users SET isActive = 0 WHERE id IN (...)`.
96
+ - **⚠⚠ `_Model_Client_Logs_Record::addNote()` IS A SILENT NO-OP OUTSIDE api2.** `addNote()` only
97
+ **stages** rows into the class's static `$_notes` array. The **only** thing that ever flushes that
98
+ array is api2's `Component/Api/V2/V2.php`. Nothing in `_underscore` and nothing in `worker2`
99
+ flushes it. So **any worker or cron that calls `addNote()` loses the note entirely** — no error, no
100
+ warning, no row. This is the most dangerous failure mode on this page, because the calling code
101
+ reads as if it audited itself.
102
+
103
+ A worker that needs a note must either drain `_Model_Client_Logs_Record::$_notes` itself and INSERT
104
+ into `Logs_<Tenant>.Record`, or write the `Record` rows directly. Note that
105
+ **`Logs_<Tenant>.Record.clientId` is `NOT NULL`**, and a worker generally does not have an `$api`
106
+ to read the client id from — resolve it from `Core` (`Clients` JOIN `Databases` on `name`). Known
107
+ ids: `Client_Compass` = **2** (`Logs_Compass`), `Client_CompassCanada` = **43**
108
+ (`Logs_CompassCanada`). This applies to **every** client, not just Compass.
93
109
  - **⚠ `Logs_*` and `Core` are on different database clusters — you cannot join them in one query.**
94
110
  `Record.recordId` → `Core.Records` and `RecordField.recordFieldId` → `Core.RecordFields` are
95
111
  logical foreign keys across a cluster boundary. Resolve the `Core` ids in a **separate** query and
@@ -104,6 +120,14 @@ single record: the milestone notes are the chapter headings, the field changes a
104
120
 
105
121
  ## Change history
106
122
 
123
+ - 2026-08-20 — Added the **`addNote()` is a silent no-op outside api2** gotcha: `addNote()` only
124
+ stages into the static `$_notes` array and **only api2's `Component/Api/V2/V2.php` ever flushes
125
+ it**, so every `addNote()` call from `_underscore` or `worker2` loses its note with no error. A
126
+ worker must drain `$_notes` itself or write `Record` rows directly, and must resolve the
127
+ `NOT NULL` `Record.clientId` from `Core` (`Clients` JOIN `Databases` on `name`) because it has no
128
+ `$api` — `Client_Compass` = 2, `Client_CompassCanada` = 43. Found while building the Compass
129
+ [stranded approval reassignment](../../../../clients/compass-usa/features/stranded-approval-reassignment.md)
130
+ engine, but it is framework-wide and client-agnostic. (bala)
107
131
  - 2026-08-13 — Documented the `Logs_<Tenant>.Record` / `RecordField` field-change audit trail as its
108
132
  own subject: the two row shapes (field-change rows with `RecordField` children vs. milestone rows
109
133
  carrying only a `note`), scoping by `Record.recordId` = the **`Core.Records` model id**
@@ -119,3 +143,7 @@ single record: the milestone notes are the chapter headings, the field changes a
119
143
  request log, the other half of "what happened to this record".
120
144
  - [Compass Approval-Decision Flow](../../../../clients/compass-usa/features/approval-decision-flow.md)
121
145
  — the investigation this technique was generalized from.
146
+ - [Stranded Approval Reassignment](../../../../clients/compass-usa/features/stranded-approval-reassignment.md)
147
+ — the worker that hit the `addNote()` no-op and had to write its own `Record` rows.
148
+ - [API Payload Interceptors](../../api2/features/api-payload-interceptors.md) — why api2 is the only
149
+ tier that flushes staged notes.
@@ -3,7 +3,7 @@
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
5
  | [API (api2 / TOGa API v2) Architecture](architecture.md) | `api2` is the backend powering the public **TOGa 2.0 API**. | api2/Controller/Index.php, api2/Component/Api/V2/V2.php, api2/Component/Api/Cxml/Cxml.php, api2/Component/Api/V2/Response/Response.php, api2/Config/ |
6
- | [API Payload Interceptors (metadata-registered prePost/postPut model hooks)](features/api-payload-interceptors.md) | A `_Model`'s `prePost()` / `postPost()` / `prePut()` / `postPut()` hooks are **not** called by the model. | api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Rate/Entitlement.php, _underscore/Model/Compass/PurchaseOrder.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/ServiceRequest.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderItem.php, dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql, dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql, dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql, dbchanges2/Client_Compass/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql, dbchanges2/Client_CompassCanada/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql |
6
+ | [API Payload Interceptors (metadata-registered prePost/postPut model hooks)](features/api-payload-interceptors.md) | A `_Model`'s `prePost()` / `postPost()` / `prePut()` / `postPut()` hooks are **not** called by the model. | api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Rate/Entitlement.php, _underscore/Model/Compass/PurchaseOrder.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/ServiceRequest.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderItem.php, _underscore/Model/Compass/ApprovalDecision.php, dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql, dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql, dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql, dbchanges2/Client_Compass/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql, dbchanges2/Client_CompassCanada/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql |
7
7
  | [Multi-Client (Cross-Client) Data Retrieval](features/cross-client-data-retrieval.md) | A single authenticated V2 GET listing can return records across **many** clients (designed for 1000+) that the caller is entitled to, honoring **each target cli | api2/Component/Api/CrossClient/CrossClient.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Cache/Table.php, _underscore/Model/Cache/Tables/Client.php, _underscore/Model/Core/Record.php, worker2/Worker/Platform/Cache.php, worker2/Controller/Index.php, worker2/_.php, dbchanges2/Cache/2026-06-30a - MultiClientCacheTables.sql, dbchanges2/Core/2026-06-30b - CacheClusterRegistrationAndRecordTtl.sql, dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql |
8
8
  | [cXML ShipNotice Gateway (ASN ingestion, carrier resolution, per-client provisioning)](features/cxml-shipnotice-gateway.md) | `_Component_Api_Cxml` accepts a supplier `ShipNoticeRequest` and translates it into a `POST /v2/advance-shipping-notices` on the V2 JSON engine. | api2/Component/Api/Cxml/Cxml.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php |
9
9
  | [Encrypted-User-UUID Auth Handoff (/auth/encrypted-user-uuid)](features/encrypted-user-uuid-auth-handoff.md) | `POST /auth/encrypted-user-uuid` is the intended **cross-client / SSO-handoff identity mechanism**: given an encrypted `{client, user}` UUID pair, it mints a fr | api2/Component/Api/CrossClient/CrossClient.php |
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-18
9
+ updated: 2026-08-20
10
10
  owners: ["mhammontree", "dfranks", "bala", "snaredla", "jcardinal"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -18,6 +18,7 @@ files:
18
18
  - _underscore/Model/Elite/ServiceRequest.php
19
19
  - _underscore/Model/Compass/SalesOrder.php
20
20
  - _underscore/Model/Compass/SalesOrderItem.php
21
+ - _underscore/Model/Compass/ApprovalDecision.php
21
22
  - dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql
22
23
  - dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql
23
24
  - dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql
@@ -32,6 +33,9 @@ related:
32
33
  - ../../_underscore/features/recursive-item-fulfillments.md
33
34
  - ../../_underscore/features/config-group-access.md
34
35
  - ../../worker2/features/creating-worker-actions.md
36
+ - ../../../clients/compass-usa/features/stranded-approval-reassignment.md
37
+ - ../../_underscore/features/record-change-audit-log.md
38
+ - ../../_underscore/features/email-template-sending.md
35
39
  - ../../../clients/elite/features/salesorder-netsuite-push.md
36
40
  - ../../../clients/compass-usa/features/sales-order-line-renumbering.md
37
41
  ---
@@ -408,6 +412,47 @@ request.** Never conclude "it works on beta" for an in-request read-after-write.
408
412
  ambiguous** ("never queued" vs "queued, never consumed"). Both are covered in
409
413
  [Creating Worker Actions](../../worker2/features/creating-worker-actions.md).
410
414
 
415
+ ## Calling a real interceptor FROM a worker — the minimal `$api` shim
416
+
417
+ The reverse direction of the section above: not "queue a worker from an interceptor" but **"run the
418
+ production interceptor from a worker"**, so a backfill or repair job reuses the business rules
419
+ instead of reimplementing them. This is the right instinct — a duplicated auto-approve threshold or
420
+ email-routing rule will drift from the real one.
421
+
422
+ **The technique: measure the actual `$api` surface, then fake exactly that.** `$api` looks
423
+ un-fakeable because it is huge, but any single interceptor chain touches very little of it. Trace the
424
+ whole chain reachable from the entry point and you usually find a handful of properties.
425
+
426
+ Worked measurement (2026-08-20, `_Model_Compass_ApprovalDecision::postPut()`): the **entire** `$api`
427
+ surface reachable from that method is **three properties** — `$api->client`, `$api->httpPayload`,
428
+ `$api->recordUuid`. So a worker can build a 3-property shim and call the genuine `prePut`/`postPut`.
429
+ The Compass repair engine does exactly this; see
430
+ [Stranded Approval Reassignment](../../../clients/compass-usa/features/stranded-approval-reassignment.md).
431
+
432
+ Rules for doing it safely:
433
+
434
+ - **Trace the chain, per class — do not generalize the surface from a sibling.**
435
+ `$api->internalApiRequest` and `$api->route` *do* appear in
436
+ `_underscore/Model/Compass/SalesOrder.php`, but only inside
437
+ `_Model_Compass_SalesOrder::postPut()` — a **different class**, not in the `ApprovalDecision`
438
+ chain. Assuming "the Compass models need `internalApiRequest`" would have produced a shim that was
439
+ both wrong and larger than needed. Re-measure for every entry point.
440
+ - **A shim is a hard-fail contract.** If the interceptor later starts reading a fourth property, the
441
+ worker gets a fatal on a `stdClass` with no such property. That is the correct outcome (loud, not
442
+ silent), but it means the shim must be built where a reviewer will see it, not buried.
443
+ - **Emails still go out.** The interceptors send real mail through the real pipeline, so a worker
444
+ that runs them is a sending path — cap it, add a `dryRun`, and commit per unit of work, because a
445
+ sent email cannot be rolled back.
446
+ - **Do not use the interceptor path just to send an email.** For a plain send there is a documented
447
+ non-API entry point: `_Model_Client_EmailTemplate::send(string $clientIdentifier, ...)`, versus the
448
+ `$api`-based `sendEmail()`. See
449
+ [Email Template Sending](../../_underscore/features/email-template-sending.md). Faking `$api` is
450
+ for reusing *business logic*, not for reaching the mailer.
451
+ - **Notes will not be logged.** `_Model_Client_Logs_Record::addNote()` is flushed only by
452
+ `Component/Api/V2/V2.php`, so notes staged by the interceptor you just ran are **dropped** in a
453
+ worker. See
454
+ [Record Change Audit Log](../../_underscore/features/record-change-audit-log.md).
455
+
411
456
  ## Worked example — the EV-10 that was not a code bug
412
457
 
413
458
  `_Model_Client_ItemFulfillment::prePost()` defaults `itemFulfillmentStageId` to the shipped stage on
@@ -505,6 +550,17 @@ and the failing environment**. It is a small table, and the drift is usually exa
505
550
 
506
551
  ## Change history
507
552
 
553
+ - 2026-08-20 — Added **"Calling a real interceptor FROM a worker — the minimal `$api` shim"**: the
554
+ reverse of the queue-from-interceptor path. A worker can reuse production interceptor logic by
555
+ measuring the `$api` surface an interceptor chain actually touches and faking only that — verified
556
+ that the **entire** surface reachable from `_Model_Compass_ApprovalDecision::postPut()` is three
557
+ properties (`client`, `httpPayload`, `recordUuid`). Recorded that the surface must be re-measured
558
+ **per class** (`$api->internalApiRequest` / `$api->route` appear in
559
+ `_Model_Compass_SalesOrder::postPut()`, a different class not in that chain), that the shim is a
560
+ deliberately loud hard-fail contract, that running interceptors really does send email, that
561
+ `_Model_Client_EmailTemplate::send($clientIdentifier, …)` is the right tool when you only want a
562
+ mail, and that staged `addNote()` notes are silently dropped outside api2. (bala)
563
+
508
564
  - 2026-08-18 — TRUE-81049: documented that a **POST-processing interceptor cannot persist by
509
565
  mutating `$payload`** — `V2.php:5934` passes `$outData`, the outbound response, which the engine
510
566
  returns and never saves, so the caller sees the value while the column stays NULL. Added the
@@ -5,7 +5,7 @@ project: _Underscore
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-08-18
8
+ updated: 2026-08-20
9
9
  owners: [jcardinal, mhammontree, dfranks, bala]
10
10
  files: []
11
11
  related:
@@ -461,6 +461,64 @@ $user->username = $username;
461
461
  if ($user->load()) { /* found */ }
462
462
  ```
463
463
 
464
+ ### A `LIMIT 1` on a non-unique key MUST have a deterministic `ORDER BY`
465
+
466
+ * **`LIMIT 1` with no `ORDER BY` does not mean "the first row" — it means an arbitrary row.** MySQL
467
+ may return whichever row it reaches first, and that choice can change with the query plan, the
468
+ index chosen, or the physical row order. If the `WHERE` clause is **not** on a unique key, the
469
+ query is nondeterministic and must carry an `ORDER BY` that breaks every tie.
470
+ * **The same bug hides in `fetchRow()` on an unordered result set** — no `LIMIT 1` to grep for, and
471
+ it reads as if it were deliberate. When auditing this pattern, search for the single-row
472
+ *accessors*, not only for `LIMIT 1`.
473
+ * **The tie-break must end in a unique column.** A business-meaning sort such as
474
+ `ORDER BY isActive DESC` still leaves two active rows tied; append `id` so the ordering is total
475
+ and the result is reproducible.
476
+ * **Order, do not filter, when "no row" is worse than "a stale row".** `WHERE isActive = 1` makes
477
+ the lookup return *nothing* for someone who has only an inactive row, which can leave a record
478
+ with no assignee at all. `ORDER BY isActive DESC, id DESC` preserves the old behaviour in that
479
+ case while making the active row win whenever one exists. Do not "tighten" such an ordering into
480
+ a filter.
481
+ * Any column you might assume is unique but is not — `email`, `contactId`, an external personnel
482
+ number — is in scope. Assume duplicates exist unless a `UNIQUE` constraint says otherwise; verify
483
+ against prod rather than trusting the column's intent.
484
+
485
+ ```php
486
+ // Bad — arbitrary row when more than one user shares the contact
487
+ $sql = "
488
+ SELECT
489
+ Users.id
490
+ FROM Users
491
+ WHERE
492
+ Users.contactId = " . ((int) $contactId) . "
493
+ LIMIT 1
494
+ ";
495
+
496
+ // Bad — the same nondeterminism, with no LIMIT 1 to notice
497
+ $user = $query->fetchRow();
498
+
499
+ // Good — active row wins, id makes the ordering total
500
+ $sql = "
501
+ SELECT
502
+ Users.id
503
+ FROM Users
504
+ WHERE
505
+ Users.contactId = " . ((int) $contactId) . "
506
+ ORDER BY
507
+ Users.isActive DESC,
508
+ Users.id DESC
509
+ LIMIT 1
510
+ ";
511
+ ```
512
+
513
+ Why this rule exists: duplicate `Users` rows are normal in tenants fed by an HR import, and an
514
+ unordered `LIMIT 1` reliably picked the **older, deactivated** row. Compass order SA135481 sat
515
+ unapproved for 6 days because the approval was stamped with a dead user id. Prod on 2026-08-20 had
516
+ 4,510 emails and 368 `contactId`s shared across more than one `Users` row. Note that ordering is
517
+ **prevention only** — it stops a bad id being written, and cannot repair rows already written.
518
+
519
+ See: clients/compass-usa/features/approval-decision-flow.md and
520
+ clients/compass-usa/features/stranded-approval-reassignment.md
521
+
464
522
  ## PHP
465
523
 
466
524
  ### PHP Tags
@@ -879,6 +937,14 @@ See: 2.0/apps/worker2/features/cross-account-aws-access.md
879
937
 
880
938
  ## Change history
881
939
 
940
+ - 2026-08-20 — Added "A `LIMIT 1` on a non-unique key MUST have a deterministic `ORDER BY`": an
941
+ unordered `LIMIT 1` (and `fetchRow()` on an unordered result, which has no `LIMIT 1` to grep for)
942
+ returns an arbitrary row, so any lookup on a non-unique key needs a tie-break ending in a unique
943
+ column. Also recorded the rule that you **order rather than filter** when "no row" is a worse
944
+ outcome than "a stale row" (`ORDER BY isActive DESC, id DESC`, not `WHERE isActive = 1`), and
945
+ that `email`/`contactId`/personnel numbers are duplicated in practice — prod 2026-08-20 had 4,510
946
+ emails and 368 `contactId`s shared across multiple `Users` rows. Drawn from Compass order
947
+ SA135481, unapproved for 6 days because its approval was stamped with a dead user id. (bala)
882
948
  - 2026-08-18 - Added "Overrides: match the parent signature exactly": adding a parameter type to an
883
949
  override whose parent leaves it untyped is a **fatal at class load** (parameter types are
884
950
  contravariant), and `php -l` does not catch it - the class dies at runtime on first model
@@ -18,7 +18,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
18
18
 
19
19
  ## 2.0 framework
20
20
 
21
- - **_underscore** (_Underscore) _(framework core)_ — 60 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
+ - **_underscore** (_Underscore) _(framework core)_ — 61 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 53 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 25 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 8 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -25,6 +25,8 @@ related:
25
25
  - workflows/grand-and-toy-asn-backfill.md
26
26
  - ../../2.0/apps/api2/features/cxml-shipnotice-gateway.md
27
27
  - ../compass-usa/features/approval-decision-flow.md
28
+ - ../compass-usa/features/stranded-approval-reassignment.md
29
+ - ../compass-usa/features/people-file-user-lifecycle.md
28
30
  - ../compass-usa/features/isfulfillable-data-quality-and-type-rule.md
29
31
  - features/french-order-email-localization.md
30
32
  - ../../2.0/apps/toga2-commerce/features/expedited-shipping-gating.md
@@ -92,6 +94,13 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
92
94
  `clientIdentifier === 'Compass_Canada'` (email-template UUID + EN/FR localization). Fixes to the
93
95
  parent cover Canada automatically — no per-tenant change. See
94
96
  [Approval-Decision Flow](../compass-usa/features/approval-decision-flow.md).
97
+ - **Canada shares the duplicate-`Users`-row / stranded-approval defect — confirmed 2026-08-20.**
98
+ `Client_CompassCanada` held **2** approvals assigned to dead duplicate user rows (vs. 53 in
99
+ `Client_Compass`). Because the PEOPLE importer class and the approval parent are both shared, the
100
+ prevention fix and the repair engine both cover Canada with no per-tenant change; Core mapping for
101
+ the log writes is `Client_CompassCanada` = `clientId 43` = `Logs_CompassCanada`. See
102
+ [Stranded Approval Reassignment](../compass-usa/features/stranded-approval-reassignment.md) and
103
+ [PEOPLE-File User Lifecycle](../compass-usa/features/people-file-user-lifecycle.md).
95
104
  - Assortment (product-grouping) names are served in fr-CA via the `AssortmentTranslations` sidecar
96
105
  (16 rows seeded). French was extracted from the old bilingual `"English/French"` `Assortments.name`
97
106
  values, which were then cleaned to English-only. See
@@ -16,6 +16,7 @@
16
16
  | [Compass PEOPLE-File User Lifecycle (duplicate accounts, reactivation grace window, raw-SQL deactivation)](features/people-file-user-lifecycle.md) | 2.0 | The nightly **PEOPLE** file cron (`_Worker_Client_Compass_PeopleFile`, `worker2/Worker/Client/Compass/PeopleFile.php`) owns the whole `Users` row lifecycle for | worker2/Worker/Client/Compass/PeopleFile.php |
17
17
  | [Persona Model & Levy-Sector Gating (worker2 PEOPLE cron)](features/persona-model-and-levy-gating.md) | 2.0 | Compass USA catalogue visibility is driven by **personas** in `Client_Compass`. | worker2/Worker/Client/Compass/PeopleFile.php |
18
18
  | [Compass Sales-Order Line-Number Renumbering (and why it hangs off the SO hooks)](features/sales-order-line-renumbering.md) | 2.0 | Compass sales-order lines must stay numbered **1..N with no gaps** after any add, edit, or delete — downstream MITS/PO linking reads `lineNumber` as an identity | _underscore/Model/Compass/SalesOrderItem.php, _underscore/Model/Compass/SalesOrder.php |
19
+ | [Stranded Approval Reassignment (repointing approvals off dead duplicate Compass Users rows)](features/stranded-approval-reassignment.md) | 2.0 | A Compass employee who leaves and comes back after more than `CONTACT_UNLINK_GRACE_DAYS` (5) is **inserted as a brand-new `Users` row** by the PEOPLE importer i | _underscore/Model/Compass/ApprovalDecision.php, worker2/Worker/Client/Compass/ApprovalReassignment.php, worker2/Worker/Client/Compass/PeopleFile.php |
19
20
  | [Compass USA](profile.md) | 2.0 | Compass USA is a TOGA client running a multi-tier supply-chain commerce operation. | |
20
21
  | [Compass Cross-Kit Bundle Corruption — Detection & Repair](workflows/cross-kit-bundle-corruption.md) | 2.0 | A frontend regression in `toga2-commerce`'s edit-order bundle submission mis-attributed bundle (kit) line items and **fees/warranties** to the **wrong kit**, pe | src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts |
21
22
  | [Compass Office Depot Duplicate PO-Line Cleanup (dual-catalog SKU)](workflows/odp-duplicate-po-line-cleanup.md) | 1.0 | The NetSuite fulfillment-sales-order importer duplicated Office Depot purchase-order lines on Compass USA because it reconciled the fulfillment SO against the e | library/app/api/toga2.php, dbchanges2/Client_Compass/2026-07-22a - CleanupOfficeDepotDuplicatePurchaseOrderItems.sql |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-18
9
+ updated: 2026-08-20
10
10
  owners: ["apeterson", "dfranks", "bala"]
11
11
  files:
12
12
  - _underscore/Model/Compass/ApprovalDecision.php
@@ -18,6 +18,7 @@ files:
18
18
  related:
19
19
  - mr-ma-order-approval-and-status.md
20
20
  - people-file-user-lifecycle.md
21
+ - stranded-approval-reassignment.md
21
22
  - ../../compass-canada/profile.md
22
23
  - ../../compass-canada/features/french-order-email-localization.md
23
24
  - ../../../2.0/apps/_underscore/features/email-template-sending.md
@@ -189,7 +190,8 @@ Both paths ultimately produce a **user id** that is stamped onto
189
190
  `ApprovalDecisions.assignedToUserId`, and from that moment the approval is bound to that *row*, not
190
191
  to that person's email — which is what makes the two gotchas below matter.
191
192
 
192
- **Email lookups must order active-first.** Both delegate lookups now end:
193
+ **Every user lookup on a non-unique key must order active-first.** As of 2026-08-20 all **9**
194
+ such lookups across the two files end:
193
195
 
194
196
  ```sql
195
197
  ORDER BY
@@ -198,10 +200,26 @@ ORDER BY
198
200
  LIMIT 1
199
201
  ```
200
202
 
201
- `Users.email` uses a case-insensitive collation, and Compass routinely has more than one `Users` row
202
- per email address (see
203
- [PEOPLE-file user lifecycle](people-file-user-lifecycle.md)), so an unordered `LIMIT 1` returned
204
- whichever row the engine happened to pick — in practice the older, deactivated one.
203
+ - `Model/Compass/SalesOrder.php` — **6**: requester by `contactId`; supervisor / manager-for-new-order
204
+ by `contactId`; both delegate-manager-by-email lookups; the VIP/manager lookup; the manager
205
+ notification lookup.
206
+ - `Model/Compass/ApprovalDecision.php` — **3**: the requester lookup in `handleApprovalDecision()`,
207
+ in `_evaluateManagerVip()`, and in `_swapManagerEmailAddress()`.
208
+
209
+ **`contactId` is just as exposed as `email` — this is not an email-only problem.** Prod 2026-08-20:
210
+ 4,510 emails are shared by more than one `Users` row (3,683 spanning an active and an inactive row)
211
+ **and 368 `contactId`s are shared** (260 spanning active and inactive). `Users.email` also uses a
212
+ case-insensitive collation. So an unordered `LIMIT 1` returned whichever row the engine happened to
213
+ pick — in practice the older, deactivated one (see
214
+ [PEOPLE-file user lifecycle](people-file-user-lifecycle.md)).
215
+
216
+ **Two of the nine had no `LIMIT 1` at all** — they called `fetchRow()` on a fully unordered result,
217
+ which hides the nondeterminism even better than an unordered `LIMIT 1` does. When auditing this
218
+ pattern, do not grep only for `LIMIT 1`.
219
+
220
+ This is **prevention only**: it stops a *new* approval being written against a dead id, and cannot
221
+ repair rows already written. The repair engine is
222
+ [Stranded Approval Reassignment](stranded-approval-reassignment.md).
205
223
 
206
224
  **Why `ORDER BY isActive DESC` and not `WHERE isActive = 1`:** filtering would make the delegate
207
225
  resolve to *nothing* when the person has **only** an inactive row, leaving the order with **no**
@@ -263,12 +281,13 @@ requester**. All of these comparisons are case-insensitive (`strcasecmp()`), mat
263
281
  link text over a `{userOrderUrl}` href that points at **commerce**.
264
282
  - **When sweeping templates, match BOTH quote styles.** Compass id `4` uses single-quoted
265
283
  `href='…'` and is missed entirely by a double-quote-only regex.
266
- - **⚠ Pre-existing SQL escaping gap in this path (separate ticket, not fixed).**
267
- `_Model_Compass_SalesOrder` ~L978 interpolates `$salesOrderUuid` straight into SQL —
268
- `SalesOrders.uuid = '" . $salesOrderUuid . "'` — while ~L1037 **in the same file** correctly uses
269
- `_Database::escape()`. The value is route-derived user input: `postPut` takes it as
270
- `end(explode('/', $api->route))`. Fix it with `_Database::escape()` per the 2.0 back-end standard;
271
- do not copy the unescaped line as a pattern.
284
+ - **SQL injection on `$salesOrderUuid` — FIXED 2026-08-20.** `_Model_Compass_SalesOrder` ~L978
285
+ interpolated `$salesOrderUuid` straight into SQL (`SalesOrders.uuid = '" . $salesOrderUuid . "'`)
286
+ while **every sibling query in the same file** already used `_Database::escape()`. The value is
287
+ route-derived user input (`postPut` takes it as `end(explode('/', $api->route))`), so this was a
288
+ real injection point, not a style nit. Now escaped. The lesson worth keeping: when one query in a
289
+ file escapes a value and another does not, the unescaped one is the bug — never assume the
290
+ inconsistency is deliberate.
272
291
  - **⚠ The approval gate is strict user-id equality, so a stale assignment is unrecoverable by the
273
292
  user.** `_Model_Client_ApprovalTemplateStage` decides "may this logged-in user act on this stage?"
274
293
  by comparing `ApprovalDecisions.assignedToUserId` against the id resolved from the logged-in user's
@@ -279,7 +298,20 @@ requester**. All of these comparisons are case-insensitive (`strcasecmp()`), mat
279
298
  it. **Do not relax the gate to match on email**: this model lives in `Model/Client/` and is shared
280
299
  by every client, so widening it widens approvals platform-wide. Fix the *data* (repoint
281
300
  `assignedToUserId`), not the gate. Duplicate-row root cause:
282
- [PEOPLE-file user lifecycle](people-file-user-lifecycle.md).
301
+ [PEOPLE-file user lifecycle](people-file-user-lifecycle.md); the automated repair is
302
+ [Stranded Approval Reassignment](stranded-approval-reassignment.md). Real incident: order
303
+ **SA135481** sat unapproved for 6 days.
304
+ - **⚠ A pure reassignment notifies nobody.** Changing only `ApprovalDecisions.assignedToUserId`
305
+ sends **no email**: `handleApprovalDecision()` returns early when `isApproved` is absent from the
306
+ payload, and `handleAssignmentNotification()` fires only for **step 1**. The manager
307
+ approval-request email is triggered when the **admin approves step 1**, not when an assignment
308
+ changes. So any tool, backfill, or admin action that repoints a manager must send its own
309
+ notification if the new approver is meant to hear about it.
310
+ - **`_evaluateManagerVip()` silently no-ops when the order has no requester `Users` row.** It runs
311
+ `INNER JOIN Users ON Users.contactId = SalesOrders.contactId`; with nothing linked to the order's
312
+ contact the query returns zero rows and the method returns without evaluating the VIP rule at all.
313
+ Pre-existing behaviour — a second, distinct way (alongside the no-supervisor POST-path gap below)
314
+ for an order to never get VIP auto-approve considered.
283
315
  - **Stale supervisor links are the bigger source of stuck approvals — and the supervisor path does no
284
316
  `isActive` check.** The supervisor-derived manager resolution performs **no validation that the
285
317
  supervisor is still active**. Prod, as of **2026-08-12**: **869 active users have a
@@ -359,6 +391,24 @@ requester**. All of these comparisons are case-insensitive (`strcasecmp()`), mat
359
391
  approve as manager on his behalf. (Fixed 2026-07-23.)
360
392
 
361
393
  ## Change history
394
+ - 2026-08-20 — **Made every non-unique user lookup in the approval path deterministic, and fixed an
395
+ SQL injection.** Added `ORDER BY Users.isActive DESC, Users.id DESC` before `LIMIT 1` to **9**
396
+ lookups — 6 in `Model/Compass/SalesOrder.php` (requester by `contactId`, supervisor/
397
+ manager-for-new-order by `contactId`, both delegate-manager-by-email, VIP/manager, manager
398
+ notification) and 3 in `Model/Compass/ApprovalDecision.php` (`handleApprovalDecision()`,
399
+ `_evaluateManagerVip()`, `_swapManagerEmailAddress()`). The 2026-08-13 round had treated this as an
400
+ email-only problem; **`contactId` is exposed identically** (prod 2026-08-20: 368 shared
401
+ `contactId`s, 260 of them spanning an active and an inactive row, alongside 4,510 shared emails).
402
+ **Two of the nine had no `LIMIT 1` at all** and called `fetchRow()` on an unordered result. Also
403
+ **fixed the previously-recorded SQL injection** at `SalesOrder.php` ~L978, which interpolated the
404
+ route-derived `$salesOrderUuid` raw while every sibling query in the file already escaped it — now
405
+ `_Database::escape()`. Newly recorded gotchas: a **pure reassignment sends no email** (early return
406
+ when `isApproved` is absent; `handleAssignmentNotification()` is step-1 only), and
407
+ **`_evaluateManagerVip()` no-ops** when no `Users` row links to the order's contact. This change is
408
+ **prevention only** — it cannot repair already-stranded rows; that is
409
+ [Stranded Approval Reassignment](stranded-approval-reassignment.md), built the same day. Prod
410
+ incident: order **SA135481**, unapproved for 6 days. `_underscore` branch `TRUE-80864`
411
+ (committed). (bala)
362
412
  - 2026-08-18 — **Migrated the supply order deep link in approval emails to the new supply route.**
363
413
  All 5 of the 2.0 link sites (Compass `SalesOrder.php` ~L1059/~L1185, `ApprovalDecision.php`
364
414
  ~L422/~L729, `Quad/SalesOrder.php` ~L71) now build
@@ -438,6 +488,8 @@ requester**. All of these comparisons are case-insensitive (`strcasecmp()`), mat
438
488
  not yet merged to `_beta`). (apeterson)
439
489
 
440
490
  ## Related docs
491
+ - [Stranded Approval Reassignment](stranded-approval-reassignment.md) — the repair half: repointing
492
+ approvals already stamped with a dead duplicate `Users` id.
441
493
  - [Compass MR/MA Order Auto-Approval & Status Gate](mr-ma-order-approval-and-status.md) — the
442
494
  auto-approval + `_status` gating half of Compass approvals (on `_Model_Compass_SalesOrder`).
443
495
  - [Compass Canada](../../compass-canada/profile.md) — uses this identical flow; the runtime
@@ -6,13 +6,14 @@ project: Worker
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-13
9
+ updated: 2026-08-20
10
10
  owners: [bala]
11
11
  files:
12
12
  - worker2/Worker/Client/Compass/PeopleFile.php
13
13
  related:
14
14
  - ./persona-model-and-levy-gating.md
15
15
  - ./approval-decision-flow.md
16
+ - ./stranded-approval-reassignment.md
16
17
  - ../profile.md
17
18
  - ../../../2.0/apps/_underscore/features/record-change-audit-log.md
18
19
  ---
@@ -43,9 +44,9 @@ behind the stuck manager approvals described in
43
44
  - the reactivation branch — flips `isActive` back to `1` for a matched inactive user.
44
45
  - the inactivation batch — a raw bulk `UPDATE` near the end of the run.
45
46
  - The class takes a **`$dbName` parameter and is written to serve more than one tenant**
46
- (US = `Client_Compass`, with a CA branch in the same class). **Whether Compass Canada is affected
47
- by the duplicate-account behaviour is UNVERIFIED** — confirm on `Client_CompassCanada` before
48
- shipping any change here, because a fix lands on both tenants at once.
47
+ (US = `Client_Compass`, with a CA branch in the same class). **Compass Canada IS affected —
48
+ confirmed 2026-08-20**: `Client_CompassCanada` carried 2 approvals stranded on dead duplicate rows
49
+ (vs. 53 in `Client_Compass`). A fix here lands on both tenants at once, which is what you want.
49
50
 
50
51
  ## How it works
51
52
 
@@ -104,35 +105,64 @@ rediscovered, and the deactivation path has no model hooks at all (see below).
104
105
  an oversight.
105
106
  - **`PeopleFile.php` contains a literal SFTP credential in class constants.** Do not paste class
106
107
  constants from this file into tickets, docs, or chat transcripts.
107
-
108
- ## Planned remediation (decided 2026-08-12 — designed, not yet built)
109
-
110
- Scope was deliberately narrowed to **one insert-time hook** in `PeopleFile.php`, placed immediately
111
- after newly inserted users are re-fetched with their new ids. For each newly created user:
112
-
113
- 1. Find other `Users` rows for the same person — match on `c_hrEmpPersonnelNbr`, fall back to
114
- `email`, excluding the newly created row.
115
- 2. Find those rows' `ApprovalDecisions` where `dtDecision IS NULL` (still pending).
116
- 3. **UPDATE** `assignedToUserId` to the new user id — never insert a replacement decision (the
117
- one-decision-per-stage invariant is documented in
118
- [Approval-Decision Flow](./approval-decision-flow.md)).
119
- 4. Write a `Logs_Compass` `Record` **note** per affected order, because the importer's raw SQL
120
- produces no automatic field logging.
121
-
122
- No `SalesOrderEmailAddresses` swap is needed — both rows share the same email address.
123
-
124
- **Deferred, not rejected:** assign-time manager validation (which would additionally cover a
125
- delegate that resolves only to dead rows, a delegate that matches nothing, and stale supervisor
126
- links); an exception report to surface the existing backlog of stuck approvals; and widening
127
- `loadLookups()` to stop duplicate creation at source.
128
-
129
- **Open product question blocking the deferred work:** when a manager has genuinely left and HR has
130
- supplied no replacement, what should the order do — block, escalate to the supervisor's supervisor,
131
- or route to an admin queue? This has not been decided, and the remaining remediation cannot be
132
- specified until it is.
108
+ - **⚠ `DRY_RUN` does NOT protect the source file — a "safe" dry run still deletes the PEOPLE file.**
109
+ The SFTP delete at the end of `ProcessPeopleFiles()` is gated on **`TEST_MODE` only**, not on
110
+ `DRY_RUN`. So running the importer with `DRY_RUN = true` still removes the PEOPLE file from
111
+ `sftp.goagilant.com` and destroys the input you were trying to inspect. Take your own copy of the
112
+ file first, or set `TEST_MODE`.
113
+ - **⚠ Read newly inserted user ids with `MAX(id)`, on the transaction connection.** A returning
114
+ employee usually keeps the **same username** as their dead row, so a plain username lookup after
115
+ the insert can return the **dead** id — which would silently defeat any repair keyed off "the new
116
+ user". And the new rows are invisible to any other connection until the tenant transaction commits,
117
+ so the read has to happen on the transaction connection.
118
+ - **`_Model_Client_Logs_Record::addNote()` is a no-op in this cron.** Staged notes are only flushed
119
+ by api2, so a worker must write its `Record` rows itself. See
120
+ [Record Change Audit Log](../../../2.0/apps/_underscore/features/record-change-audit-log.md).
121
+ - **`Client_Compass.Users` is 176,057 rows with no index on `c_hrEmpPersonnelNbr`, `email`, or
122
+ `isActive`.** Any per-row lookup you add against those columns is a full scan of ~174k rows. An
123
+ index migration is recommended and **not yet done**.
124
+
125
+ ## Remediation — BUILT 2026-08-20
126
+
127
+ The insert-time hook designed on 2026-08-12 was built, and it grew into a reusable engine rather
128
+ than inline importer code. Full subject doc:
129
+ **[Stranded Approval Reassignment](./stranded-approval-reassignment.md)**. Summary of what changed
130
+ here in `PeopleFile.php`:
131
+
132
+ - The importer is now a **thin trigger**. It collects the ids of the users it just created and calls
133
+ the worker action `Client/Compass/ApprovalReassignment/Reassign` — the matching, safety and
134
+ reassignment logic live on `_Model_Compass_ApprovalDecision`, not in the importer.
135
+ - The trigger fires **after the tenant transaction commits**, so the interceptors it runs (and the
136
+ emails they queue) can never belong to a transaction that later rolls back.
137
+ - Matching is one non-blank `c_hrEmpPersonnelNbr`, else one non-blank `email`, and it acts **only
138
+ when exactly one active user matches**.
139
+ - The historic backlog (53 US / 2 CA as of 2026-08-20) was cleared by **direct `UPDATE` SQL, not by
140
+ running the job** — the sweep would have emailed managers about orders up to 11 months old. See
141
+ the reassignment doc before ever pointing the sweep at an aged backlog.
142
+
143
+ **Still deferred, still not rejected:** assign-time manager validation (a delegate resolving only to
144
+ dead rows, a delegate matching nothing, stale supervisor links); an exception report for the standing
145
+ backlog; and widening `loadLookups()` to stop duplicate creation at source.
146
+
147
+ **Open product question, still open:** when a manager has genuinely left and HR has supplied no
148
+ replacement, what should the order do — block, escalate to the supervisor's supervisor, or route to
149
+ an admin queue? The engine deliberately **skips** these as ambiguous rather than guessing, so the
150
+ question is now contained rather than blocking.
133
151
 
134
152
  ## Change history
135
153
 
154
+ - 2026-08-20 — **Built the remediation** that this doc had recorded as designed-but-unbuilt: the
155
+ importer is now a thin trigger that collects newly created user ids and calls the new worker action
156
+ `Client/Compass/ApprovalReassignment/Reassign` **after the tenant transaction commits**; all the
157
+ matching and safety logic lives on `_Model_Compass_ApprovalDecision`. Full subject:
158
+ [Stranded Approval Reassignment](./stranded-approval-reassignment.md). Resolved the standing
159
+ **UNVERIFIED** question — **Compass Canada IS affected** (2 stranded approvals in
160
+ `Client_CompassCanada` vs. 53 in `Client_Compass`, measured on prod). New gotchas recorded: the
161
+ SFTP source-file delete is gated on **`TEST_MODE` only, so a `DRY_RUN = true` run still deletes the
162
+ PEOPLE file**; newly inserted ids must be read with `MAX(id)` **on the transaction connection**
163
+ because a returning employee reuses their old username; `addNote()` is a no-op in a worker; and
164
+ `Users` (176,057 rows) has **no index** on `c_hrEmpPersonnelNbr`, `email`, or `isActive`. Ticket
165
+ TRUE-80864. (bala)
136
166
  - 2026-08-13 — Documented the `Users` lifecycle half of the PEOPLE importer as its own subject.
137
167
  Root-caused duplicate Compass user accounts to `loadLookups()`, which only sees active users plus
138
168
  those deactivated within `CONTACT_UNLINK_GRACE_DAYS` (5): anyone deactivated longer ago cannot be
@@ -151,5 +181,7 @@ specified until it is.
151
181
  half of the same cron.
152
182
  - [Compass Approval-Decision Flow](./approval-decision-flow.md) — what a duplicate account breaks
153
183
  downstream.
184
+ - [Stranded Approval Reassignment](./stranded-approval-reassignment.md) — the engine that repairs the
185
+ approvals a duplicate account stranded.
154
186
  - [Record Change Audit Log](../../../2.0/apps/_underscore/features/record-change-audit-log.md) — why
155
187
  the importer's raw writes leave no audit trail.
@@ -0,0 +1,206 @@
1
+ ---
2
+ title: Stranded Approval Reassignment (repointing approvals off dead duplicate Compass Users rows)
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: compass-usa
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-08-20
10
+ owners: [bala]
11
+ files:
12
+ - _underscore/Model/Compass/ApprovalDecision.php
13
+ - worker2/Worker/Client/Compass/ApprovalReassignment.php
14
+ - worker2/Worker/Client/Compass/PeopleFile.php
15
+ related:
16
+ - ./approval-decision-flow.md
17
+ - ./people-file-user-lifecycle.md
18
+ - ../profile.md
19
+ - ../../compass-canada/profile.md
20
+ - ../../../2.0/apps/api2/features/api-payload-interceptors.md
21
+ - ../../../2.0/apps/_underscore/features/record-change-audit-log.md
22
+ - ../../../2.0/apps/worker2/features/creating-worker-actions.md
23
+ ---
24
+
25
+ ## Summary
26
+
27
+ A Compass employee who leaves and comes back after more than `CONTACT_UNLINK_GRACE_DAYS` (5) is
28
+ **inserted as a brand-new `Users` row** by the PEOPLE importer instead of being reactivated (root
29
+ cause in [PEOPLE-file user lifecycle](./people-file-user-lifecycle.md)). Any pending approval that
30
+ was stamped with the old row's id is then invisible to the live account, because the can-approve
31
+ gate in the shared `_Model_Client_ApprovalTemplateStage` is a **strict `assignedToUserId` =
32
+ `Users.id`** match. The person keeps getting chased by email (both rows share the address) about an
33
+ order that is not in their queue.
34
+
35
+ There are two halves to the answer, and they are **not** interchangeable:
36
+
37
+ 1. **Prevention** — every lookup that resolves a user by a non-unique key must order active-first
38
+ so a *new* approval can never be written against a dead id. Shipped; see
39
+ [Approval-Decision Flow](./approval-decision-flow.md).
40
+ 2. **Repair** — this doc. An engine on `_Model_Compass_ApprovalDecision` that **repoints already
41
+ stranded** `ApprovalDecisions.assignedToUserId` onto the live duplicate. Prevention cannot fix
42
+ rows that were already written.
43
+
44
+ Real incident that drove it: order **SA135481** sat unapproved for 6 days because its step-2 decision
45
+ was assigned to the dead row.
46
+
47
+ ## Key files / entry points
48
+
49
+ - **`_underscore/Model/Compass/ApprovalDecision.php`** — the engine. Two public entry points:
50
+ - `reassignApprovalsStrandedOnDuplicatesOfActiveUsers()` — **scoped** mode, driven by the
51
+ per-import trigger with the ids of the users the importer just created.
52
+ - `reassignAllApprovalsStrandedOnInactiveDuplicates()` — **sweep** mode, the whole-tenant backfill.
53
+ - **`worker2/Worker/Client/Compass/ApprovalReassignment.php`** — new dispatchable worker action
54
+ `Client/Compass/ApprovalReassignment/Reassign`. **Defaults `dryRun = true`** — it does nothing
55
+ destructive unless a caller explicitly passes `dryRun=false`.
56
+ - **`worker2/Worker/Client/Compass/PeopleFile.php`** — reduced to a thin trigger: it collects the ids
57
+ of newly created users and calls the action **after the tenant transaction commits**.
58
+
59
+ Both Compass tenants are covered by the same code, because all the behaviour lives in the shared
60
+ parent `_Model_Compass_ApprovalDecision` and the tenant subclasses are empty (see
61
+ [Approval-Decision Flow](./approval-decision-flow.md)).
62
+
63
+ ## How it works
64
+
65
+ ### The matching rule — one active twin or nothing
66
+
67
+ For a stranded decision assigned to an inactive user, the engine looks for the same human by:
68
+
69
+ 1. the same **non-blank `c_hrEmpPersonnelNbr`**, else
70
+ 2. the same **non-blank `email`**.
71
+
72
+ It acts **only when exactly one active user matches**. Zero matches or more than one match is
73
+ **skipped as ambiguous** — the row is left alone for a human. This is the same "mechanically fixable
74
+ vs. genuinely departed" split already recorded in
75
+ [Approval-Decision Flow](./approval-decision-flow.md): *same human, new account* is repointable;
76
+ *manager who actually left* is not, and guessing would be worse than stalling.
77
+
78
+ `Users.email` collates `utf8mb4_unicode_ci`, so plain `=` is already case-insensitive — the email
79
+ arm needs no `LOWER()` and must not have one (see gotchas).
80
+
81
+ ### Safety invariants
82
+
83
+ These are the load-bearing rules of the engine. Anything that changes it must preserve all four:
84
+
85
+ - **UPDATE only, never INSERT.** Every approval keeps exactly its two decision rows. The
86
+ one-decision-per-stage / max-two-per-approval invariant is prod-verified and enforced upstream by
87
+ `Approvals_UNIQUE2` (one `Approval` per sales order, on `recordId` + `primaryKeyId`).
88
+ - **Never touch a decided row.** A decision with `dtDecision` set is history and is skipped.
89
+ - **Delegate to the real interceptors, do not re-implement.** The engine calls the genuine
90
+ `prePut` / `postPut` rather than copying the VIP auto-approve rule, so the threshold and the email
91
+ routing keep living in exactly one place. The mechanics of building the minimal `$api` needed for
92
+ that are in
93
+ [API Payload Interceptors](../../../2.0/apps/api2/features/api-payload-interceptors.md).
94
+ - **Commit per decision, not per sweep.** Emails are sent by the interceptors, and an email cannot
95
+ be rolled back — so a crash halfway through must never leave emails sent for work that was undone.
96
+
97
+ ### Batching
98
+
99
+ The run caps at **5000 decisions** and returns `hasMoreToProcess`. The sweep is **idempotent**
100
+ (a repointed decision no longer matches the stranded predicate), so a larger backlog is cleared by
101
+ simply re-running rather than by raising the cap.
102
+
103
+ ### The trigger has to read new ids with MAX(id) on the transaction connection
104
+
105
+ A returning employee usually keeps the **same username** as their dead row. So a plain
106
+ username lookup after the insert can return the **dead** id, and the trigger would "repair" the
107
+ approval onto the row it was already stuck on. `PeopleFile` therefore resolves the new ids with
108
+ `MAX(id)`, and it must do so **on the transaction connection** — the rows are not visible to another
109
+ connection until the tenant transaction commits.
110
+
111
+ The action itself is invoked **after** that commit, so the interceptors it runs (and the emails they
112
+ queue) can never belong to a transaction that later rolls back.
113
+
114
+ ## Measured state (prod, 2026-08-20 — point-in-time, not standing facts)
115
+
116
+ - Stranded backlog: **53** in `Client_Compass`, **2** in `Client_CompassCanada`, **0 ambiguous** in
117
+ either. It grows roughly **2 per week**.
118
+ - Duplicate exposure in `Client_Compass.Users` (176,057 rows, 101,116 inactive): **4,510 emails**
119
+ shared by more than one row (3,683 of them spanning an active and an inactive row) and **368
120
+ `contactId`s** shared (260 spanning active and inactive). **`contactId` is exposed to the same bug
121
+ as `email`** — the earlier fix round had treated this as an email-only problem.
122
+ - Core mapping for the logs writes: `Client_Compass` = `clientId 2` = `Logs_Compass`;
123
+ `Client_CompassCanada` = `clientId 43` = `Logs_CompassCanada`.
124
+
125
+ ## Deferred — the missing indexes
126
+
127
+ `Client_Compass.Users` has **no index on `c_hrEmpPersonnelNbr`, `email`, or `isActive`**. `EXPLAIN`
128
+ on the stranded-approval query shows `type=ALL` on `ApprovalDecisions` (79,061 rows) plus a
129
+ hash-joined full scan of `Users` (174,463 rows). An index migration in `dbchanges2` is
130
+ **recommended and NOT done** — tolerable today only because the sweep runs nightly against a
131
+ 53-row backlog, not because the query is cheap.
132
+
133
+ ## Gotchas / known issues
134
+
135
+ - **⚠ The backfill was deliberately done by direct SQL, not by running this job.** Running the sweep
136
+ with `dryRun=false` over the historic backlog would have emailed managers about orders up to
137
+ **11 months old** (oldest stranded order 2025-09-10) and performed **2 VIP auto-approvals with no
138
+ human involved**. The one-off catch-up was done with plain `UPDATE` SQL instead, which sends
139
+ nothing and writes no audit rows. **The worker job exists for the nightly per-import path and
140
+ future on-demand use — do not point it at an aged backlog without deciding about the emails
141
+ first.** Consequence to be aware of: the historic rows repaired by hand carry **no `Record` note**,
142
+ so the audit trail for them is git plus this doc, not `Logs_Compass`.
143
+ - **A pure reassignment sends NO email.** Changing only `assignedToUserId` notifies nobody:
144
+ `handleApprovalDecision()` returns early when `isApproved` is absent from the payload, and
145
+ `handleAssignmentNotification()` only fires for step 1. The manager approval-request email fires
146
+ when the **admin approves step 1**, not when an assignment changes. Anyone who expects the new
147
+ approver to be told has to add that explicitly.
148
+ - **`_evaluateManagerVip()` silently no-ops when the order has no requester user.** It joins
149
+ `INNER JOIN Users ON Users.contactId = SalesOrders.contactId`; with no `Users` row linked to the
150
+ order's contact the query returns zero rows and the function returns without ever evaluating the
151
+ VIP rule. Pre-existing behaviour, not introduced here — but it means some orders never get VIP
152
+ auto-approve considered at all. Same family as the no-supervisor POST-path gap in
153
+ [Approval-Decision Flow](./approval-decision-flow.md).
154
+ - **Do not wrap `Users.email` in `LOWER()`.** The column collates `utf8mb4_unicode_ci`, so `LOWER()`
155
+ changes **no results** while making the predicate non-sargable. It is a pure loss.
156
+ - **`_Model_Client_Logs_Record::addNote()` does nothing in a worker.** The engine's log notes had to
157
+ be written explicitly. See
158
+ [Record Change Audit Log](../../../2.0/apps/_underscore/features/record-change-audit-log.md).
159
+ - **Prevention and repair must both stay.** Deleting the active-first `ORDER BY` because "the sweep
160
+ fixes it anyway" would reintroduce a window where a person is chased for days before the next run.
161
+
162
+ ## Verification
163
+
164
+ Replayed **two real production stranded approvals** on a local schema cloned from `Client_Compass`
165
+ and `Logs_Compass`: person `muehea01`, personnel `1451024`, dead id `79444`, live id `280900`;
166
+ orders **SA135553** (admin had approved) and **SA135628** (admin had rejected). The two route
167
+ differently and that difference is the point — SA135553 reassigned **and emailed**, SA135628
168
+ reassigned and **silent**. 27/27 assertions on the replay, plus a 55-scenario / 147-assertion suite
169
+ over the matching rules, the safety invariants, dry run, scoped vs. sweep mode, the VIP rule, email
170
+ routing, log notes, and a 600-row volume case.
171
+
172
+ ## Change history
173
+
174
+ - 2026-08-20 — **Built the repair engine for approvals stranded on dead duplicate `Users` rows**
175
+ (the remediation that had been designed-but-unbuilt in
176
+ [PEOPLE-file user lifecycle](./people-file-user-lifecycle.md)). Added
177
+ `reassignApprovalsStrandedOnDuplicatesOfActiveUsers()` (scoped, per-import) and
178
+ `reassignAllApprovalsStrandedOnInactiveDuplicates()` (sweep) to
179
+ `_Model_Compass_ApprovalDecision`, plus worker action
180
+ `Client/Compass/ApprovalReassignment/Reassign` (`dryRun` defaults **true**); `PeopleFile.php`
181
+ became a thin trigger firing **after** the tenant transaction commits. Matching is one non-blank
182
+ `c_hrEmpPersonnelNbr` else one non-blank `email`, and **only when exactly one active user
183
+ matches** — anything else is skipped as ambiguous. Invariants: UPDATE-only (never INSERT), never
184
+ touch a row with `dtDecision`, delegate to the real `prePut`/`postPut` so the VIP threshold stays
185
+ in one place, and commit per decision so a crash cannot leave emails sent for rolled-back work;
186
+ capped at 5000/run with `hasMoreToProcess` and idempotent. Recorded that the trigger must read new
187
+ ids with `MAX(id)` **on the transaction connection** because a returning employee usually reuses
188
+ their old username. **DECIDED the historic backfill goes through plain `UPDATE` SQL, not this job**
189
+ — the sweep would have emailed managers about orders up to 11 months old and auto-approved 2 VIP
190
+ orders unattended. Also recorded: a pure reassignment sends no email at all;
191
+ `_evaluateManagerVip()` no-ops when no `Users` row links to the order's contact; the prod
192
+ backlog/duplicate/collation measurements; and the **missing `Users` indexes (not done)**. Verified
193
+ by replaying real prod orders SA135553/SA135628 (27/27) plus 55 scenarios / 147 assertions.
194
+ Ticket TRUE-80864. Engine plus worker action **not yet committed** at capture time (held in
195
+ `_underscore` `git stash@{0}` plus uncommitted worker2 files). (bala)
196
+
197
+ ## Related docs
198
+
199
+ - [Compass Approval-Decision Flow](./approval-decision-flow.md) — the prevention half (active-first
200
+ lookups) and the strict user-id approval gate this works around.
201
+ - [Compass PEOPLE-File User Lifecycle](./people-file-user-lifecycle.md) — why the duplicate `Users`
202
+ rows exist at all.
203
+ - [API Payload Interceptors](../../../2.0/apps/api2/features/api-payload-interceptors.md) — how to
204
+ run the real `prePut`/`postPut` from a worker instead of duplicating the logic.
205
+ - [Record Change Audit Log](../../../2.0/apps/_underscore/features/record-change-audit-log.md) — why
206
+ `addNote()` had to be replaced with an explicit write.
@@ -18,7 +18,7 @@ project: _Underscore
18
18
  client: compass-usa
19
19
  type: profile
20
20
  status: active
21
- updated: 2026-08-17
21
+ updated: 2026-08-20
22
22
  owners: [jcardinal, bala, tcox, apeterson, dfranks]
23
23
  files: []
24
24
  related:
@@ -31,6 +31,7 @@ related:
31
31
  - features/cost-centers.md
32
32
  - features/isfulfillable-data-quality-and-type-rule.md
33
33
  - features/approval-decision-flow.md
34
+ - features/stranded-approval-reassignment.md
34
35
  - workflows/cross-kit-bundle-corruption.md
35
36
  - workflows/odp-duplicate-po-line-cleanup.md
36
37
  - features/odp-edi-855-acknowledgement-and-overquantity-guard.md
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.623",
3
+ "version": "1.0.624",
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",