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.
- package/knowledge/2.0/apps/_underscore/features/record-change-audit-log.md +29 -1
- package/knowledge/2.0/apps/api2/INDEX.md +1 -1
- package/knowledge/2.0/apps/api2/features/api-payload-interceptors.md +57 -1
- package/knowledge/2.0/standards/backend-php.md +67 -1
- package/knowledge/INDEX.md +1 -1
- package/knowledge/clients/compass-canada/profile.md +9 -0
- package/knowledge/clients/compass-usa/INDEX.md +1 -0
- package/knowledge/clients/compass-usa/features/approval-decision-flow.md +65 -13
- package/knowledge/clients/compass-usa/features/people-file-user-lifecycle.md +62 -30
- package/knowledge/clients/compass-usa/features/stranded-approval-reassignment.md +206 -0
- package/knowledge/clients/compass-usa/profile.md +2 -1
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
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-
|
|
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-
|
|
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
|
package/knowledge/INDEX.md
CHANGED
|
@@ -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)_ —
|
|
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-
|
|
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
|
-
**
|
|
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
|
-
`
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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-
|
|
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). **
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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-
|
|
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