toga-ai 1.0.567 → 1.0.569
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/INDEX.md +1 -0
- package/knowledge/2.0/apps/_underscore/features/email-send-pipeline.md +28 -1
- package/knowledge/2.0/apps/_underscore/features/record-change-audit-log.md +121 -0
- package/knowledge/2.0/apps/_underscore/features/string-html-entity-helpers.md +12 -1
- package/knowledge/2.0/apps/api2/INDEX.md +1 -1
- package/knowledge/2.0/apps/api2/features/api-payload-interceptors.md +45 -1
- package/knowledge/2.0/apps/api2/features/request-logging.md +49 -6
- package/knowledge/2.0/apps/worker2/features/creating-worker-actions.md +38 -1
- package/knowledge/INDEX.md +3 -3
- package/knowledge/clients/compass-usa/INDEX.md +5 -2
- package/knowledge/clients/compass-usa/features/approval-decision-flow.md +95 -2
- package/knowledge/clients/compass-usa/features/mits-po-to-so-item-linking.md +12 -3
- package/knowledge/clients/compass-usa/features/mits-po-zero-line-item-rejection.md +158 -0
- package/knowledge/clients/compass-usa/features/odp-edi-855-acknowledgement-and-overquantity-guard.md +116 -0
- package/knowledge/clients/compass-usa/features/people-file-user-lifecycle.md +155 -0
- package/knowledge/clients/compass-usa/features/persona-model-and-levy-gating.md +11 -1
- package/knowledge/clients/compass-usa/profile.md +15 -2
- package/knowledge/clients/compass-usa/workflows/odp-order-pipeline-to-netsuite.md +31 -1
- package/package.json +1 -1
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
| [Legacy page meta (Page::meta) & context-scoped ClientRecordFieldSettings](features/page-meta-context-field-settings.md) | `_Model_Core_Page::meta()` is the **legacy** page-meta resolver behind `GET /pages/meta?slug=<slug>` — still the live path for `toga2-supply` and other pre-Surf | _underscore/Model/Core/Page.php, _underscore/Model/Client/TableView.php |
|
|
35
35
|
| [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/Query.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php, api2/Controller/Index.php |
|
|
36
36
|
| [Persona Name Translation (PersonaTranslations sidecar)](features/persona-name-translation.md) | Serves Persona **names** in multiple languages by adding a per-language **sidecar** table `PersonaTranslations`, reusing the platform's existing metadata-driven | _underscore/Model/Client/PersonaTranslation.php, dbchanges2/Client/2026-07-22b - PersonaTranslations.sql, dbchanges2/Core/2026-07-22a - PersonaTranslationsRecord.sql, dbchanges2/Client/2026-07-22c - PersonaTranslationsAcl.sql, dbchanges2/Client_CompassCanada/2026-07-22 - PersonaTranslationsFrench.sql, toga2-commerce/src/pages/Account/view/MySettingsView.tsx |
|
|
37
|
+
| [Record Change Audit Log (Logs_<Client>.Record / RecordField) — reading a field's history](features/record-change-audit-log.md) | Every 2.0 client schema has a sibling **logs** schema `Logs_<Tenant>` (e.g. | _underscore/Model/Client/Logs/Record.php, _underscore/Model/Client/Logs/RecordField.php, _underscore/Model/Client/Logs/CustomRecordField.php |
|
|
37
38
|
| [Recursive Item Fulfillments (upstream mirroring)](features/recursive-item-fulfillments.md) | In a multi-tier supply chain a sales order (SO) spawns a purchase order (PO) that becomes another SO downstream, and so on. | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillmentItem.php, _underscore/Model/Client/ItemFulfillmentItemUnit.php, _underscore/Model/Client/ItemFulfillmentPackage.php, _underscore/Model/Compass/AdvanceShippingNotice.php, dbchanges2/Core/2026-02-13 - 75601 - RecursiveItemFulfillmentCreation.sql, dbchanges2/Core/2026-06-04 - RecursiveItemFulfillmentPut.sql, dbchanges2/Client_Compass/2026-07-02a - FixSA133377TrackingSerialAndDuplicateIF.sql |
|
|
38
39
|
| [_String helpers — ASCII-safe HTML entity encoding (and the parseBetween trap)](features/string-html-entity-helpers.md) | `_String` is the 2.0 framework's static string utility class. | _underscore/String.php |
|
|
39
40
|
| [Surface Resolver (_Model_Core_Surface::resolve — replaces Page::meta)](features/surface-resolver.md) | The runtime for the platform-wide **Surface** UI presentation layer: 9 `_underscore` models plus a cached resolver, `_Model_Core_Surface::resolve(&$api, string | _underscore/Model/Core/Surface.php, _underscore/Model/Client/AclRecordScript.php, _underscore/Model/Core/RecordScript.php, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Quad/2026-07-01a - GrantSurfacesMetaGroupScriptAcl.sql, dbchanges2/Client_CompassCanada/2026-07-01a - GrantSurfacesMetaGroupScriptAcl.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Client_Compass/2026-07-15f - SalesOrderRecordActionsRemoveDeadConfigRuleOverrides.sql, dbchanges2/Core/2026-07-17h - Update - ClearApprovalsFilterButtonConfig.sql, dbchanges2/Client_Compass/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Compass/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_Quad/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Quad/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, _underscore/Model/Core/SurfaceElement.php, _underscore/Model/Core/Action.php, _underscore/Model/Core/Vocabulary.php, _underscore/Model/Core/VocabularyTerm.php, _underscore/Model/Core/Message.php, _underscore/Model/Client/SurfaceOverride.php, _underscore/Model/Client/MessageTranslation.php, _underscore/Model/Client/ThemeToken.php, _underscore/Model/Core/Page.php, api2/Component/Api/V2/V2.php |
|
|
@@ -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-13
|
|
10
10
|
owners: ["bala", "tcox", "dfranks"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Email.php
|
|
@@ -52,6 +52,26 @@ SMTP** using PHPMailer, then flips each row to `SENT` or `FAILED`. So a 2.0 emai
|
|
|
52
52
|
4. On success the row flips to `SENT`; on failure `retryCount` is incremented and the row is
|
|
53
53
|
retried on subsequent runs until `MAX_RETRIES` (3), after which it is marked `FAILED`.
|
|
54
54
|
|
|
55
|
+
### `send()` resolves the log database itself — from the clientIdentifier
|
|
56
|
+
|
|
57
|
+
`send()` does not take a database name. It resolves `Logs_<Client>` by joining
|
|
58
|
+
**`Core.Clients` → `Databases` → `DatabaseHosts`** for the current environment and region, and
|
|
59
|
+
**throws unless exactly one row matches**. So the string passed to `setClientIdentifier()` must equal
|
|
60
|
+
**`Core.Clients.clientIdentifier`** exactly — it is a lookup key, not a label.
|
|
61
|
+
|
|
62
|
+
Verified in production, beta and dev: `Compass_Usa` → `Logs_Compass`, `Compass_Canada` →
|
|
63
|
+
`Logs_CompassCanada` (note the identifier and the schema suffix are **not** the same string, so never
|
|
64
|
+
derive one from the other).
|
|
65
|
+
|
|
66
|
+
**Diagnosis order for a missing alert email**, in this order — each step tells you which side failed:
|
|
67
|
+
|
|
68
|
+
1. **`Core.WorkerJobs`** — is there a row for the action? (Absent is ambiguous; see
|
|
69
|
+
[Creating Worker Actions](../../worker2/features/creating-worker-actions.md).)
|
|
70
|
+
2. **`Logs_<Client>.Email`** — is there a `PENDING` row? If yes, `_Email::send()` worked and the
|
|
71
|
+
problem is transmission.
|
|
72
|
+
3. **Does that row clear to `SENT`?** If it stays `PENDING`, the Send cron is not draining that
|
|
73
|
+
client's log DB.
|
|
74
|
+
|
|
55
75
|
## Gotchas / known issues
|
|
56
76
|
|
|
57
77
|
- **It is a POLL, not an enqueue — and the docblock lies.** The comment at `_underscore/Email.php`
|
|
@@ -140,6 +160,13 @@ SMTP** using PHPMailer, then flips each row to `SENT` or `FAILED`. So a 2.0 emai
|
|
|
140
160
|
for a hotfix.
|
|
141
161
|
|
|
142
162
|
## Change history
|
|
163
|
+
- 2026-08-13 — Documented that **`send()` resolves the `Logs_<Client>` database itself** from the
|
|
164
|
+
client identifier (`Core.Clients` → `Databases` → `DatabaseHosts` for the environment + region) and
|
|
165
|
+
**throws unless exactly one row matches** — so `setClientIdentifier()` takes a lookup key that must
|
|
166
|
+
equal `Core.Clients.clientIdentifier`, not a display label (`Compass_Usa` → `Logs_Compass`,
|
|
167
|
+
`Compass_Canada` → `Logs_CompassCanada`; verified in prod, beta and dev). Added the three-step
|
|
168
|
+
diagnosis order for a missing alert (`Core.WorkerJobs` → a `PENDING` `Logs_<Client>.Email` row →
|
|
169
|
+
whether it clears), found while building the Compass PO-rejection alert. No code change. (bala)
|
|
143
170
|
- 2026-08-05 — Corrected the model: this is a **poll**, not an enqueue — the `_Email::send()`
|
|
144
171
|
docblock (~line 320) claiming it "enqueues a Worker 2 job" is **stale**; the Send action SELECTs
|
|
145
172
|
`PENDING` rows per client. Recorded the consequence that a `PENDING` row written from a **local**
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Record Change Audit Log (Logs_<Client>.Record / RecordField) — reading a field's history
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: _underscore
|
|
5
|
+
project: _Underscore
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-08-13
|
|
10
|
+
owners: [bala]
|
|
11
|
+
files:
|
|
12
|
+
- _underscore/Model/Client/Logs/Record.php
|
|
13
|
+
- _underscore/Model/Client/Logs/RecordField.php
|
|
14
|
+
- _underscore/Model/Client/Logs/CustomRecordField.php
|
|
15
|
+
related:
|
|
16
|
+
- ../../api2/features/request-logging.md
|
|
17
|
+
- ../../../../clients/compass-usa/features/approval-decision-flow.md
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Summary
|
|
21
|
+
|
|
22
|
+
Every 2.0 client schema has a sibling **logs** schema `Logs_<Tenant>` (e.g. `Logs_Compass`) whose
|
|
23
|
+
`Record` / `RecordField` tables are the platform's **field-level change audit trail**. Together they
|
|
24
|
+
answer "who changed this field, to what, and when" for *any* model — which makes them the primary
|
|
25
|
+
forensic tool when a record is in a state nobody can explain.
|
|
26
|
+
|
|
27
|
+
The single most useful property: **a change made outside the API leaves a `Record` row's field
|
|
28
|
+
children empty — or no row at all.** That absence is how you distinguish a direct SQL edit from an
|
|
29
|
+
in-app change, which is usually the first fork in any "how did this record get like this?"
|
|
30
|
+
investigation.
|
|
31
|
+
|
|
32
|
+
This is the **model-write** audit log. It is a different thing from the **request** log
|
|
33
|
+
(`Logs_<Client>.Api` / core `Logs.Api`) documented in
|
|
34
|
+
[V2 Request Logging](../../api2/features/request-logging.md) — that one records HTTP traffic; this
|
|
35
|
+
one records the resulting field mutations.
|
|
36
|
+
|
|
37
|
+
## Key files / entry points
|
|
38
|
+
|
|
39
|
+
- **`_underscore/Model/Client/Logs/Record.php`** — `_Model_Client_Logs_Record`,
|
|
40
|
+
`DATABASE = DB_CLIENT_LOGS`, `TABLE = 'Record'`. Columns:
|
|
41
|
+
`id`, `uuid`, `recordId` (FK → `_Model_Core_Record`), `primaryKeyId`, `clientId`, `apiId`,
|
|
42
|
+
`userId`, `timestamp`, `note`.
|
|
43
|
+
- **`_underscore/Model/Client/Logs/RecordField.php`** — `_Model_Client_Logs_RecordField`,
|
|
44
|
+
`TABLE = 'RecordField'`. Columns: `id`, `uuid`, `logRecordId` (FK → `Record.id`),
|
|
45
|
+
`recordFieldId` (FK → `_Model_Core_RecordField`), `oldValue`, `newValue`. Also declares the
|
|
46
|
+
method constants `READ` / `ADD` / `EDIT` / `DELETE`.
|
|
47
|
+
- `_Model_Client_Logs_Record::lookup(&$api, $route, $uuid)` — resolves a route + record uuid into
|
|
48
|
+
the record-logs read, i.e. the same lens the UI uses.
|
|
49
|
+
|
|
50
|
+
## How it works
|
|
51
|
+
|
|
52
|
+
### The two row shapes
|
|
53
|
+
|
|
54
|
+
`Record` rows come in two distinct flavours, and reading them as one thing is a common mistake:
|
|
55
|
+
|
|
56
|
+
1. **Field-change rows** — a `Record` row with one or more **`RecordField` children**. Each child
|
|
57
|
+
names the changed field (`recordFieldId` → `Core.RecordFields`) and carries `oldValue` /
|
|
58
|
+
`newValue`. This is a write that went **through the API/model layer**.
|
|
59
|
+
2. **Milestone / note rows** — a `Record` row with a human-readable **`note`** and **no
|
|
60
|
+
`RecordField` children**. These are written deliberately by model interceptors to narrate
|
|
61
|
+
business events. Compass sales orders, for example, emit notes such as
|
|
62
|
+
`"Delegate manager assigned: <email>"`, `"Admin Approved"`, `"Manager Approved"`, and
|
|
63
|
+
`"Manager Approval Request Email sent to Manager (...)"`.
|
|
64
|
+
|
|
65
|
+
### Reconstructing one record's history
|
|
66
|
+
|
|
67
|
+
Scope by **model** and **primary key**:
|
|
68
|
+
|
|
69
|
+
- `Record.recordId` is the **`Core.Records` id of the model**, not the row. Useful ids seen so far:
|
|
70
|
+
**`SalesOrders` = 14**, **`ApprovalDecisions` = 179**. Resolve others from `Core.Records`
|
|
71
|
+
(`route` / `model` columns) rather than guessing.
|
|
72
|
+
- `Record.primaryKeyId` is the id of the row **in that model's table** (it is a foreign key to many
|
|
73
|
+
models, so it is meaningless without `recordId`).
|
|
74
|
+
- Join `RecordField ON RecordField.logRecordId = Record.id` for the field-level detail, then
|
|
75
|
+
translate `recordFieldId` through `Core.RecordFields` to get the field name.
|
|
76
|
+
|
|
77
|
+
Ordering by `Record.timestamp` for a fixed `(recordId, primaryKeyId)` gives the full narrative of a
|
|
78
|
+
single record: the milestone notes are the chapter headings, the field changes are the detail.
|
|
79
|
+
|
|
80
|
+
## Gotchas
|
|
81
|
+
|
|
82
|
+
- **⚠ A change made by direct SQL leaves NO `RecordField` row.** Anything that bypasses the model
|
|
83
|
+
layer — a manual DB edit, a `dbchanges2` migration, or a worker that issues a raw
|
|
84
|
+
`_Query`/`UPDATE` — mutates the data without producing a field-change entry. So **absence of a
|
|
85
|
+
`RecordField` row is positive evidence that the change did not come through the app.** This is the
|
|
86
|
+
cleanest way to tell "someone edited the database" from "someone clicked a button", and it is
|
|
87
|
+
worth checking *before* hunting for a code path that could have produced the state.
|
|
88
|
+
- **Therefore: a worker doing raw bulk updates is invisible here.** If a job needs its effects to be
|
|
89
|
+
auditable, it must write its own `Record` **note** row explicitly — it will get nothing
|
|
90
|
+
automatically. Real example:
|
|
91
|
+
[Compass PEOPLE-file user lifecycle](../../../../clients/compass-usa/features/people-file-user-lifecycle.md),
|
|
92
|
+
whose deactivation batch is a raw `UPDATE Users SET isActive = 0 WHERE id IN (...)`.
|
|
93
|
+
- **⚠ `Logs_*` and `Core` are on different database clusters — you cannot join them in one query.**
|
|
94
|
+
`Record.recordId` → `Core.Records` and `RecordField.recordFieldId` → `Core.RecordFields` are
|
|
95
|
+
logical foreign keys across a cluster boundary. Resolve the `Core` ids in a **separate** query and
|
|
96
|
+
substitute the literals, or you will write a query that simply cannot run.
|
|
97
|
+
- **`note` is free text, not an enum.** Milestone strings are written by interceptor code and can be
|
|
98
|
+
reworded by any change to that code. Match them with `LIKE` on a stable prefix, never on equality
|
|
99
|
+
with a full sentence, and treat their absence as weak evidence (the note may predate the code that
|
|
100
|
+
writes it).
|
|
101
|
+
- **Do not paste logged values into tickets or the KB.** `oldValue`/`newValue` and note text
|
|
102
|
+
routinely contain user emails and other personal data. Reference the rows by location; quote the
|
|
103
|
+
mechanism, not the people.
|
|
104
|
+
|
|
105
|
+
## Change history
|
|
106
|
+
|
|
107
|
+
- 2026-08-13 — Documented the `Logs_<Tenant>.Record` / `RecordField` field-change audit trail as its
|
|
108
|
+
own subject: the two row shapes (field-change rows with `RecordField` children vs. milestone rows
|
|
109
|
+
carrying only a `note`), scoping by `Record.recordId` = the **`Core.Records` model id**
|
|
110
|
+
(`SalesOrders` = 14, `ApprovalDecisions` = 179) plus `primaryKeyId`, and the two rules that make it
|
|
111
|
+
a diagnostic: **a write outside the model layer leaves no `RecordField` row** (so absence proves a
|
|
112
|
+
direct-SQL edit), and **`Logs_*` is on a different cluster from `Core`**, so the id lookups cannot
|
|
113
|
+
be joined in a single query. Generalized from a Compass manager-assignment investigation; no code
|
|
114
|
+
change. (bala)
|
|
115
|
+
|
|
116
|
+
## Related docs
|
|
117
|
+
|
|
118
|
+
- [V2 Request Logging & Where Requests Land](../../api2/features/request-logging.md) — the HTTP
|
|
119
|
+
request log, the other half of "what happened to this record".
|
|
120
|
+
- [Compass Approval-Decision Flow](../../../../clients/compass-usa/features/approval-decision-flow.md)
|
|
121
|
+
— the investigation this technique was generalized from.
|
|
@@ -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-13
|
|
10
10
|
owners: ["bala"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/String.php
|
|
@@ -58,6 +58,12 @@ numbers.
|
|
|
58
58
|
- **Prefer this helper over a raw `htmlentities()` call** in 2.0 email code. Existing call sites
|
|
59
59
|
that use the bare 4-argument `htmlentities()` are correct for *named*-entity characters only;
|
|
60
60
|
they leak U+202F-class characters.
|
|
61
|
+
- **⚠ The explicit-flags trap applies identically to `htmlspecialchars()`.** Passing any flags
|
|
62
|
+
(e.g. `ENT_QUOTES`) **overrides** PHP 8.1's default `ENT_QUOTES | ENT_SUBSTITUTE`, so on invalid
|
|
63
|
+
UTF-8 the call returns an **empty string** and the field silently disappears. Always write
|
|
64
|
+
**`ENT_QUOTES | ENT_SUBSTITUTE`**. Scope of the risk: `json_decode` rejects malformed UTF-8, so an
|
|
65
|
+
inbound API payload cannot deliver a bad byte — but a **directly invoked or DB-scheduled worker**
|
|
66
|
+
can, which is exactly where an escaping helper tends to live.
|
|
61
67
|
- **⚠ OPEN BUG — `parseBetween()` throws whenever it is called case-sensitively with an ending
|
|
62
68
|
string.** Two named arguments are wrong for `strpos()`, whose first parameter is `$haystack`,
|
|
63
69
|
not `$string`:
|
|
@@ -73,6 +79,11 @@ numbers.
|
|
|
73
79
|
|
|
74
80
|
## Change history
|
|
75
81
|
|
|
82
|
+
- 2026-08-13 — Recorded that the **explicit-flags / `ENT_SUBSTITUTE` trap is identical for
|
|
83
|
+
`htmlspecialchars()`** (passing flags drops PHP 8.1's default, so invalid UTF-8 returns an empty
|
|
84
|
+
string and the field vanishes), and scoped the exposure: `json_decode` rejects malformed UTF-8 so an
|
|
85
|
+
inbound API payload cannot carry it, but a directly invoked or DB-scheduled **worker** can. Surfaced
|
|
86
|
+
writing the Compass PO-rejection email escaper. (bala)
|
|
76
87
|
- 2026-08-05 — Added `_String::encodeToAsciiHtmlEntities()`: `htmlentities` with
|
|
77
88
|
`ENT_QUOTES | ENT_SUBSTITUTE` / `double_encode: false`, then a `preg_replace_callback` numbering
|
|
78
89
|
**every** remaining character above U+007F, because `htmlentities()` only converts characters
|
|
@@ -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/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 |
|
|
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/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 |
|
|
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
|
| [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 |
|
|
9
9
|
| [ENVIRONMENT (not the EB environment name) decides the _underscore branch and Config file](features/environment-variable-drives-underscore-branch.md) | An api2 Elastic Beanstalk instance decides **which `_underscore` branch it clones** and **which `Config/<env>.ini` it loads** from the EB environment property * | api2/.ebextensions/git.php, api2/.ebextensions/php_include_underscore.config, api2/.ebextensions/git.sandbox-dev.json, api2/Config/beta.ini, api2/Config/sandbox-dev.ini, api2/Component/Api/V2/V2.php |
|
|
@@ -6,12 +6,13 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-13
|
|
10
10
|
owners: ["mhammontree", "dfranks", "bala", "snaredla"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
13
13
|
- api2/Controller/Index.php
|
|
14
14
|
- _underscore/Model/Client/ItemFulfillment.php
|
|
15
|
+
- _underscore/Model/Compass/PurchaseOrder.php
|
|
15
16
|
- _underscore/Model/Elite/SalesOrder.php
|
|
16
17
|
- _underscore/Model/Elite/ServiceRequest.php
|
|
17
18
|
- _underscore/Model/Compass/SalesOrder.php
|
|
@@ -72,6 +73,24 @@ and prod — so a validation added to `_Model_Compass_PurchaseOrder::prePost` ca
|
|
|
72
73
|
tenant's other APIs at all. See
|
|
73
74
|
[Compass MITS PO → SO Item Linking](../../../clients/compass-usa/features/mits-po-to-so-item-linking.md).
|
|
74
75
|
|
|
76
|
+
The lookup predicate is **`apiId IS NULL OR apiId = <the caller's api>`**, so a row with a concrete
|
|
77
|
+
`apiId` scopes the hook to exactly that one API and a row with `apiId NULL` opens it to all of them.
|
|
78
|
+
|
|
79
|
+
**Worked example, verified in prod *and* dev-sandbox (2026-08-13):** `Core.ApiPayloadInterceptors`
|
|
80
|
+
has **no row at all** for `recordId 17` (`purchase-orders`), while **both** Compass tenants carry
|
|
81
|
+
their own `PRE` + `POST` rows in their client DB — `Client_Compass` and
|
|
82
|
+
`Client_CompassCanada`, `isActive = 1`, `apiId = 2` (which is *MITS Service Hub* in Compass and
|
|
83
|
+
*MITS Compass Canada* in Canada; the id happens to match, the name does not). Two consequences:
|
|
84
|
+
|
|
85
|
+
- A guard in `_Model_Compass_PurchaseOrder::prePost` **cannot fire for the Agilant api (id 1)** —
|
|
86
|
+
the api the 1.0 worker ODP/EDI crons authenticate as (`App_Api_Toga2::API_UUID_COMPASS`). Their
|
|
87
|
+
header-only POs, which carry no `purchaseOrderItems` key at all, are structurally unreachable from
|
|
88
|
+
that hook. See
|
|
89
|
+
[Compass MITS PO zero-line-item rejection](../../../clients/compass-usa/features/mits-po-zero-line-item-rejection.md).
|
|
90
|
+
- **A new sub-client tenant of an existing client family inherits the PHP but not the rows.** Add a
|
|
91
|
+
Compass sub-client and its interceptors never fire until someone inserts rows into *its* client DB
|
|
92
|
+
— silently, with no error.
|
|
93
|
+
|
|
75
94
|
**One client / all APIs is the `Core` table with `clientId` set and `apiId` NULL** — it does not
|
|
76
95
|
have to live in `Client_<X>.ApiPayloadInterceptors`. Elite's service-request and sales-order rows
|
|
77
96
|
were registered that way in `Core` (`clientId 41`, `apiId NULL`, `minDepth 1`, records **35**
|
|
@@ -337,6 +356,19 @@ and the failing environment**. It is a small table, and the drift is usually exa
|
|
|
337
356
|
`isActive = 1` — and remember every environment activates independently.
|
|
338
357
|
- **⚠ A post interceptor's `$payload` is the record, not a route-keyed envelope** — and it is
|
|
339
358
|
by-reference, so pull the record into a local instead of reassigning `$payload`.
|
|
359
|
+
- **⚠ Validate that a payload ARRAY's elements are objects before touching them — `property_exists()`
|
|
360
|
+
THROWS.** `property_exists()` raises a `TypeError` when argument 1 is an `array`, `int`, `null` or
|
|
361
|
+
`bool` (a *string* merely returns `false`). So a `foreach` over e.g. `$payload->purchaseOrderItems`
|
|
362
|
+
that calls `property_exists($item, …)` turns a malformed payload into an **HTTP 500** where the
|
|
363
|
+
interceptor was supposed to answer **400**. Reject non-array containers and non-object elements
|
|
364
|
+
**first**, with a plain `_Exception_Validation` (a sender coding fault is not a business condition —
|
|
365
|
+
it should not raise an Issue or page anyone). Worked example:
|
|
366
|
+
[Compass MITS PO zero-line-item rejection](../../../clients/compass-usa/features/mits-po-zero-line-item-rejection.md).
|
|
367
|
+
- **⚠ A throw from a PRE interceptor leaves NO `Logs_<Client>.Api` row.** The transaction log is
|
|
368
|
+
written near the END of `_Component_Api_V2::execute()` (~L2225-2271), *after* route processing
|
|
369
|
+
(~L1742-2043), so an exception raised from `prePost` never reaches it and the rejected request
|
|
370
|
+
looks like it never arrived. See
|
|
371
|
+
[V2 request logging](./request-logging.md#-a-throw-from-a-pre-interceptor-leaves-no-api-log-row-at-all).
|
|
340
372
|
- **⚠ Client behaviour never goes on `_Model_Client_X`.** Every tenant with a row for that record
|
|
341
373
|
inherits it through `parent::` calls. Put it on `_Model_<Client>_X`.
|
|
342
374
|
- **A client's rows may be in `Core` (with `clientId`), not in `Client_<X>`** — check both before
|
|
@@ -365,6 +397,18 @@ and the failing environment**. It is a small table, and the drift is usually exa
|
|
|
365
397
|
|
|
366
398
|
## Change history
|
|
367
399
|
|
|
400
|
+
- 2026-08-13 — TRUE-80412, from building the Compass zero-line-item PO guard. (1) Recorded the
|
|
401
|
+
lookup predicate — **`apiId IS NULL OR apiId = <caller's api>`** — and the verified row set for
|
|
402
|
+
`recordId 17` (`purchase-orders`): **no `Core` row at all**, and a `PRE` + `POST` pair in **each**
|
|
403
|
+
Compass tenant's own client DB (`isActive = 1`, `apiId = 2` = *MITS Service Hub* in Compass,
|
|
404
|
+
*MITS Compass Canada* in Canada), identical in prod and dev-sandbox. So a guard in
|
|
405
|
+
`_Model_Compass_PurchaseOrder::prePost` **cannot fire for the Agilant api (id 1)** that the 1.0
|
|
406
|
+
ODP/EDI crons authenticate as, and **a new sub-client tenant inherits the PHP but not the rows** —
|
|
407
|
+
silently. (2) Added the **`property_exists()` `TypeError`** gotcha: it throws on an array/int/null/
|
|
408
|
+
bool argument, so a payload-element loop must reject non-object elements first or a malformed
|
|
409
|
+
payload 500s where the interceptor meant to answer 400. (3) Cross-linked the finding that a throw
|
|
410
|
+
from a **pre** interceptor leaves **no `Logs_<Client>.Api` row** (the log write sits near the end of
|
|
411
|
+
`execute()`, after route processing). (bala)
|
|
368
412
|
- 2026-08-12 — TRUE-80498/80501, from building the Elite service-request chain. (1) **A
|
|
369
413
|
post-processing `$payload` is the record itself (or an array of records), NOT wrapped in a route
|
|
370
414
|
key** — api2 applies the route envelope only *after* the post interceptors run, so
|
|
@@ -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-13
|
|
10
10
|
owners: ["mhammontree", "dfranks", "bala"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -56,12 +56,45 @@ transaction. A rejection therefore still writes a **full `Client_Logs.Api` / `Co
|
|
|
56
56
|
row** — request payload, response payload, `apiId`, `sourceIp`, `transactionId` — while the
|
|
57
57
|
business row is discarded.
|
|
58
58
|
|
|
59
|
-
So a 400 **is** SQL-queryable and attributable to the calling credential
|
|
60
|
-
|
|
59
|
+
So a 400 **is** SQL-queryable and attributable to the calling credential — **provided the request
|
|
60
|
+
got far enough to be logged at all** (see the pre-interceptor exception below, which is the common
|
|
61
|
+
case for a rejection and does **not** produce a row). What a 400 lacks is a `Logs.Issue` row:
|
|
62
|
+
**no alerting, no aggregation, no owner** (see
|
|
61
63
|
[Error Reporting — Issue/Event Capture](../../_underscore/features/error-reporting-issue-event.md)).
|
|
62
|
-
That is the deciding factor when choosing between `_Exception_Validation` (400,
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
That is the deciding factor when choosing between `_Exception_Validation` (400, no Issue) and
|
|
65
|
+
`_Exception_Business` (**4xx** since 2026-08-04 — it extends `_Exception_Validation` — plus Sentry
|
|
66
|
+
and a routed Issue).
|
|
67
|
+
|
|
68
|
+
### ⚠ A throw from a PRE interceptor leaves NO Api-log row at all
|
|
69
|
+
|
|
70
|
+
`_Component_Api_V2::execute()` writes the inbound transaction log **near its END**
|
|
71
|
+
(`api2/Component/Api/V2/V2.php` ~L2225-2271), *after* the route processing that dispatches the
|
|
72
|
+
interceptors (~L1742-2043). An exception raised from a `prePost` hook therefore unwinds straight to
|
|
73
|
+
the outer `catch` in `api2/Controller/Index.php` and **skips the log write entirely** — the
|
|
74
|
+
commit-the-logs / roll-back-the-data contract above has nothing to commit.
|
|
75
|
+
|
|
76
|
+
**Symptom, and it is deeply misleading:** the caller gets a 400 whose envelope has
|
|
77
|
+
`transactionId`, `timestamp`, `authority` and `audience` **all null**, `error` `EV-1`, and the real
|
|
78
|
+
explanation buried in **`messages[0].identifiers.message`**. Meanwhile `Logs_<Client>.Api` and core
|
|
79
|
+
`Logs.Api` contain **nothing** — so a deliberately rejected request is indistinguishable from a
|
|
80
|
+
request that never arrived. This cost hours of wrong diagnosis on 2026-08-12.
|
|
81
|
+
|
|
82
|
+
**Consequences:**
|
|
83
|
+
|
|
84
|
+
- **The only durable record of an interceptor rejection is the `Logs.Issue` row** written by
|
|
85
|
+
`_Error::captureException` — which exists only if you threw `_Exception_Business` (a plain
|
|
86
|
+
`_Exception_Validation` raises no Issue, so that rejection leaves **no trace anywhere**).
|
|
87
|
+
- **Never conclude "MITS/ServiceNow never posted" from an empty Api log** when the endpoint has a
|
|
88
|
+
validating pre-interceptor. Check `Logs.Issue` / `Logs.Event` first.
|
|
89
|
+
- This applies to **every** pre-interceptor rejection on the platform, including Prudential's REQ
|
|
90
|
+
rejections and the Compass zero-line-item PO guard — which is why both of those alert through a
|
|
91
|
+
**worker-dispatched email**, not through the log.
|
|
92
|
+
See [Compass MITS PO zero-line-item rejection](../../../clients/compass-usa/features/mits-po-zero-line-item-rejection.md)
|
|
93
|
+
and [Prudential REQ rejection alert](../../../clients/prudential/features/service-request-rejection-alert-email.md).
|
|
94
|
+
|
|
95
|
+
Do **not** reach for `_Exception_Business` merely to "make sure the rejection is recorded" — reach
|
|
96
|
+
for it when the rejection is a **business condition someone must act on**, and accept that a plain
|
|
97
|
+
validation rejection from an interceptor is genuinely unrecorded.
|
|
65
98
|
|
|
66
99
|
### ⚠ But an uncaught PHP **fatal** can leave NO log row anywhere
|
|
67
100
|
|
|
@@ -177,6 +210,16 @@ re-send.
|
|
|
177
210
|
|
|
178
211
|
## Change history
|
|
179
212
|
|
|
213
|
+
- 2026-08-13 — **Important limit on the "an HTTP 400 leaves a full log row" rule** (TRUE-80412):
|
|
214
|
+
`execute()` writes the transaction log near its **end** (~L2225-2271), *after* route processing
|
|
215
|
+
(~L1742-2043), so **a throw from a PRE interceptor produces no `Logs.Api` row at all** — the
|
|
216
|
+
rejected request is indistinguishable from one that never arrived. Recorded the misleading
|
|
217
|
+
response shape (null `transactionId`/`timestamp`/`authority`/`audience`, `EV-1`, real text inside
|
|
218
|
+
`messages[0].identifiers.message`) and that the **`Logs.Issue`** row is then the only durable
|
|
219
|
+
record — which a plain `_Exception_Validation` does not create, leaving that rejection with no
|
|
220
|
+
trace anywhere. This cost hours of wrong diagnosis. Also corrected a stale line that still called
|
|
221
|
+
`_Exception_Business` a **500**; it has answered **4xx** since it began extending
|
|
222
|
+
`_Exception_Validation` on 2026-08-04. No code change. (bala)
|
|
180
223
|
- 2026-08-11 — Documented how **apiproxy's cross-region 5xx retry distorts both logs**, found while
|
|
181
224
|
reading prod 500s for Compass: one user action writes up to **six** near-identical core `Logs.Api`
|
|
182
225
|
500 rows sharing a single `transactionId` (~2 s apart, alternating `instanceId`), so occurrence
|
|
@@ -6,7 +6,7 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-13
|
|
10
10
|
owners: [jcardinal, dfranks, mhammontree, tcox, bala]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/
|
|
@@ -39,6 +39,11 @@ Lambda code changes are needed** — you just create the PHP file.
|
|
|
39
39
|
(`$class::$method(...$parameters)`), which PHP binds as **named arguments** — so each method
|
|
40
40
|
parameter name *is* the queue's parameter contract. Do **not** collapse a multi-param action
|
|
41
41
|
into a single `array` argument: named-argument dispatch then has no key to bind to and breaks.
|
|
42
|
+
Two corollaries of named binding: a `parameters` key with **no matching parameter** is a **fatal
|
|
43
|
+
`Error: Unknown named parameter`**, and a parameter you declare **required** (no default) makes an
|
|
44
|
+
omission a loud `ArgumentCountError` instead of a silent default — which is what you want for
|
|
45
|
+
anything that selects a **tenant**, since a defaulted client identifier mis-attributes work to the
|
|
46
|
+
wrong client and nothing errors.
|
|
42
47
|
3. **Return** — return `string`; `json_encode()` structured data.
|
|
43
48
|
4. **`initialize()`** — optional `public static function initialize()` the framework calls
|
|
44
49
|
automatically before any method in the class (register DB connections / shared setup).
|
|
@@ -223,6 +228,27 @@ Debugging consequence: **no row means either "the caller never queued it" or "th
|
|
|
223
228
|
and never consumed."** Those have completely different fixes. Distinguish them with the **SQS queue
|
|
224
229
|
metrics**, not the database.
|
|
225
230
|
|
|
231
|
+
#### Telling "the tier stopped consuming" apart WITHOUT SQS metrics
|
|
232
|
+
|
|
233
|
+
There is a database-side tell, because cron rows keep arriving from the scheduler even while nothing
|
|
234
|
+
completes. Query `Core.WorkerJobs` for the environment and compare **created vs. completed** for the
|
|
235
|
+
day:
|
|
236
|
+
|
|
237
|
+
- **Healthy** — completions track creations, and the newest `dtCompleted` is minutes old.
|
|
238
|
+
- **Dead tier** — a large created count with almost nothing completed, and the **last routine
|
|
239
|
+
completion hours in the past**. Measured on **dev-sandbox, 2026-08-12**: **1,548 jobs created that
|
|
240
|
+
day, 1,547 never completed**, last routine completion **2026-08-11 16:00**, while per-minute cron
|
|
241
|
+
rows kept accumulating.
|
|
242
|
+
|
|
243
|
+
Two things follow, and both have misled people:
|
|
244
|
+
|
|
245
|
+
- **A stalled tier produces no error anywhere.** Your own SQS-dispatched job yields *no `WorkerJobs`
|
|
246
|
+
row at all* (the row is written on consumption), so it reads exactly like "the caller never
|
|
247
|
+
queued it" — and if you conclude that, you go and debug working code.
|
|
248
|
+
- **⚠ Backlogged messages fire RETROACTIVELY when the tier recovers.** A test you gave up on hours
|
|
249
|
+
ago will run — and send its email — later. Never assume a queued side effect is dead just because
|
|
250
|
+
it didn't happen; on an alerting action that means a real recipient gets a stale alert.
|
|
251
|
+
|
|
226
252
|
## ⚠ Renaming an action is a CROSS-REPO rename — deploy both sides together
|
|
227
253
|
|
|
228
254
|
The action name is a **string** held by the producers, not a symbol the compiler can check. Renaming
|
|
@@ -287,6 +313,17 @@ Rules: rename **both sides in one PR and one deploy**; grep every repo for the o
|
|
|
287
313
|
commit-before-SQS transaction pattern that the worker relies on.
|
|
288
314
|
|
|
289
315
|
## Change history
|
|
316
|
+
- 2026-08-13 — Added a **database-side test for "the tier stopped consuming"**, which resolves the
|
|
317
|
+
absent-`WorkerJobs`-row ambiguity without SQS metrics: compare created vs. completed
|
|
318
|
+
`Core.WorkerJobs` for the day — on dev-sandbox 2026-08-12, **1,548 created / 1,547 never
|
|
319
|
+
completed**, last routine completion 2026-08-11 16:00, while per-minute cron rows kept
|
|
320
|
+
accumulating. Recorded that a stalled tier is **indistinguishable from "never queued"** (no error
|
|
321
|
+
anywhere, because the row is written on consumption) and that **backlogged SQS messages fire
|
|
322
|
+
retroactively on recovery**, so an abandoned test can send its email hours later. Also noted two
|
|
323
|
+
corollaries of named-argument dispatch: an unmatched `parameters` key is a fatal
|
|
324
|
+
*Unknown named parameter*, and a **required** parameter is the right way to make a tenant selector
|
|
325
|
+
fail loudly (`ArgumentCountError`) instead of defaulting to the wrong client. Surfaced building the
|
|
326
|
+
Compass PO-rejection alert action. (bala)
|
|
290
327
|
- 2026-08-12 — Added **"renaming an action is a cross-repo rename"**: the action name is a string
|
|
291
328
|
held by the producer (usually an `_underscore` model, a cron row or a migration in another repo),
|
|
292
329
|
so renaming the worker method alone makes `runTask` dispatch to a name that no longer exists —
|
package/knowledge/INDEX.md
CHANGED
|
@@ -5,7 +5,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
5
5
|
## 1.0 framework
|
|
6
6
|
|
|
7
7
|
- **library** (Library) _(framework core)_ — 17 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
|
|
8
|
-
- **worker** (Worker) —
|
|
8
|
+
- **worker** (Worker) — 23 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
|
|
9
9
|
- **dbchanges** (Database Changes) _(framework core)_ — 1 doc(s) → [1.0/apps/dbchanges/INDEX.md](1.0/apps/dbchanges/INDEX.md)
|
|
10
10
|
- **worker1.5** (Worker 1.5) — 0 doc(s) → [1.0/apps/worker1.5/INDEX.md](1.0/apps/worker1.5/INDEX.md)
|
|
11
11
|
- **togadesk** (TOGa Desk) — 12 doc(s) → [1.0/apps/togadesk/INDEX.md](1.0/apps/togadesk/INDEX.md)
|
|
@@ -18,8 +18,8 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
18
18
|
|
|
19
19
|
## 2.0 framework
|
|
20
20
|
|
|
21
|
-
- **_underscore** (_Underscore) _(framework core)_ —
|
|
22
|
-
- **worker2** (Worker) —
|
|
21
|
+
- **_underscore** (_Underscore) _(framework core)_ — 58 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
|
|
22
|
+
- **worker2** (Worker) — 49 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
|
|
23
23
|
- **api2** (API) — 23 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
25
|
- **toga2-supply** (TOGa Supply) — 6 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
|
|
@@ -2,22 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
| Doc | Framework | Summary | Files |
|
|
4
4
|
|-----|-----------|---------|-------|
|
|
5
|
-
| [Compass Approval-Decision Flow (Notifications & Manager Reassignment)](features/approval-decision-flow.md) | 2.0 | Compass's sales-order approval flow — approval/notification email lists, **manager reassignment**, VIP auto-approve, and EN/FR localization — lives **entirely i | _underscore/Model/Compass/ApprovalDecision.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/Usa/ApprovalDecision.php, _underscore/Model/Compass/Canada/ApprovalDecision.php |
|
|
5
|
+
| [Compass Approval-Decision Flow (Notifications & Manager Reassignment)](features/approval-decision-flow.md) | 2.0 | Compass's sales-order approval flow — approval/notification email lists, **manager reassignment**, VIP auto-approve, and EN/FR localization — lives **entirely i | _underscore/Model/Compass/ApprovalDecision.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/Usa/ApprovalDecision.php, _underscore/Model/Compass/Canada/ApprovalDecision.php, _underscore/Model/Client/ApprovalTemplateStage.php |
|
|
6
6
|
| [Compass ASN → ItemFulfillment Auto-Creation](features/asn-to-item-fulfillment.md) | 2.0 | For Compass USA, posting an AdvanceShippingNotice (ASN) auto-creates the ItemFulfillment (IF) on the upstream SalesOrder. | _underscore/Model/Compass/AdvanceShippingNotice.php, _underscore/Model/Compass/PurchaseOrder.php, api2/Component/Api/Cxml/Cxml.php, dbchanges2/Client_Compass/2026-06-11 - AsnItemTrackingNumberAcl.sql, dbchanges2/Client_Compass/2026-06-15b - BackfillSA132781ItemFulfillmentTracking.sql, dbchanges2/Client_Compass/2026-06-16 - CleanupSA132763CrossLineTracking.sql, dbchanges2/Client_Compass/2026-06-16b - CleanupSA132743CrossLineTracking.sql, dbchanges2/Client_Compass/2026-06-16c - BackfillSA132763C40QYUCTracking.sql, dbchanges2/Client_Compass/2026-06-18a - CleanupSA132898DuplicateTracking.sql, dbchanges2/Client_Compass/2026-06-18b - CleanupSA132881DuplicateTracking.sql, dbchanges2/Client_Compass/2026-07-02a - FixSA133377TrackingSerialAndDuplicateIF.sql |
|
|
7
7
|
| [Cost Centers — Unit Locations, numeric-only policy](features/cost-centers.md) | 2.0 | A Compass "cost center" — the value a user picks in commerce and that lands on an order — is **not** a `CostCenters` row. | toga2-commerce/src/pages/Cart/api/CartApi.ts, worker1.5/crons/toga2/compass/import_locations.php, _underscore/Model/Compass/SalesOrder.php, api2/Component/Api/V2/V2.php, dbchanges2/Client_Compass/2026-07-06 - RemoveNonNumericCostCenters.sql |
|
|
8
8
|
| [Compass isFulfillable — Data Quality & the Type-Derived Rule](features/isfulfillable-data-quality-and-type-rule.md) | 2.0 | A **prod-data investigation (2026-08-12, read-only)** into why so many Compass storefront lines still have a dead **Qty Fulfilled** cell. | _underscore/Model/Client/Item.php, library/app/api/toga2.php, worker/crons/toga2/netsuite/backfill_isfulfillable_all_clients.php, toga2-supply/src/pages/Orders/view/OrderView/components/sections/OrderItemsTableSection.tsx |
|
|
9
9
|
| [Compass: Item-Fulfillment TableViews (for-sales-order-items & for-sales-orders, tracking via bridge)](features/item-fulfillment-tracking-tableview.md) | 2.0 | Two sibling Compass TableViews in `Client_Compass` display fulfilled items in toga2-supply, both driven by `TableViews` / `TableViewJoins` / `TableViewFields` c | dbchanges2/Client_Compass/2026-06-10 - ItemFulfillmentsForSalesOrderItemsTableView.sql, dbchanges2/Client_Compass/2026-06-11 - ItemFulfillmentsForSalesOrdersTableView.sql, dbchanges2/Client_Compass/2026-06-15a - FixItemFulfillmentTrackingNumberJoins.sql, dbchanges2/Client/2026-07-15a - ExcludeFeeItemsFromItemFulfillmentsForSalesOrdersView.sql |
|
|
10
10
|
| [Compass MITS PO → SO Item Linking](features/mits-po-to-so-item-linking.md) | 2.0 | MITS sends Compass inbound Purchase Orders (`POST /v2/purchase-orders`) against a Sales Order (`mitsSalesOrder`). | _underscore/Model/Compass/PurchaseOrder.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php |
|
|
11
11
|
| [Compass MITS PO Transmission to Vendors](features/mits-po-transmission-to-vendors.md) | 2.0 | The 1.0 worker cron `2_transmit_mits_purchase_orders_to_vendors.php` transmits Compass PurchaseOrders to their vendors (Office Depot, Strategic Systems, Compass | worker/crons/toga2/compass/workflow/2_transmit_mits_purchase_orders_to_vendors.php, worker/crons/toga2/compasscanada/workflow/2_transmit_mits_purchase_orders_to_vendors.php, library/app/client/compass.php |
|
|
12
|
+
| [Compass MITS PO rejection — zero line items (400 + Issue + PM alert email)](features/mits-po-zero-line-item-rejection.md) | 2.0 | MITS transmits Compass purchase orders that carry **`"purchaseOrderItems": []`** (real example: PO `50309196-1`, `mitsSalesOrder` `MR244630`). | _underscore/Model/Compass/PurchaseOrder.php, _underscore/Model/Compass/Usa/PurchaseOrder.php, _underscore/Model/Compass/Canada/PurchaseOrder.php, worker2/Worker/Client/Compass/reports/PurchaseOrderRejectionEmail.php |
|
|
12
13
|
| [Compass MITS Sales-Order Transmission — Rejection Alerting (Issue/Event, not email-in-cron)](features/mits-sales-order-transmission-alerting.md) | 2.0 | When MITS **rejects** a Compass sales order transmitted by the 1.0 cron `1_transmit_compass_sales_orders_to_mits.php`, the alert is no longer an email built ins | worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php, worker2/Worker/Infrastructure/Errors.php, _underscore/Model/Core/Logs/Issue.php, _underscore/Model/Core/Logs/IssueEmailAddress.php, dbchanges2/Logs/2026-08-04a - MITS rejection business recipients.sql |
|
|
13
14
|
| [Compass MR/MA Order Auto-Approval & Status Gate](features/mr-ma-order-approval-and-status.md) | 2.0 | Compass **MR** and **MA** sales orders are system-generated from the MITS / Office Depot EDI pipeline (they do not originate as user-entered SA orders) and must | _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/PurchaseOrder.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php |
|
|
15
|
+
| [Compass Office Depot EDI 855 Acknowledgement + Over-Quantity PO Guard](features/odp-edi-855-acknowledgement-and-overquantity-guard.md) | 1.0 | How Compass acknowledges Office Depot (ODP) inbound **EDI 850** purchase orders with an **X12 855**, and the **over-quantity guard** that rejects a duplicate PO | worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php, worker/crons/toga2/compass/workflow/4_transmit_office_depot_po_acknowledgements.php, library/app/edi.php |
|
|
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 |
|
|
14
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 |
|
|
15
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 |
|
|
16
19
|
| [Compass USA](profile.md) | 2.0 | Compass USA is a TOGA client running a multi-tier supply-chain commerce operation. | |
|
|
17
20
|
| [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 |
|
|
18
21
|
| [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 |
|
|
19
22
|
| [Recovering a Lost Compass ODP EDI 850 Import (re-drop from Logs.FileLog)](workflows/odp-edi-import-recovery.md) | 1.0 | How to recover a Compass **Office Depot EDI 850** import that failed partway — the case where cron **3a** created the ODP SalesOrder header, the follow-up item | worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php, worker/crons/toga2/compass/workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php, worker/schedules/cron.worker.sync.json |
|
|
20
|
-
| [Compass ODP Order Pipeline to NetSuite (numbered worker crons)](workflows/odp-order-pipeline-to-netsuite.md) | 1.0 | The end-to-end **Compass Office Depot (ODP) order → NetSuite** pipeline as it actually runs through the 1.0 `worker` crons under `worker/crons/toga2/compass/`, | worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php, worker/crons/toga2/compass/workflow/2_transmit_mits_purchase_orders_to_vendors.php, worker/crons/toga2/compass/edi/1_download_edi_s3_create_po_toga.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php, worker/crons/toga2/compass/workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php, worker/schedules/cron.worker.sync.json, library/app/client/compass.php |
|
|
23
|
+
| [Compass ODP Order Pipeline to NetSuite (numbered worker crons)](workflows/odp-order-pipeline-to-netsuite.md) | 1.0 | The end-to-end **Compass Office Depot (ODP) order → NetSuite** pipeline as it actually runs through the 1.0 `worker` crons under `worker/crons/toga2/compass/`, | worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php, worker/crons/toga2/compass/workflow/2_transmit_mits_purchase_orders_to_vendors.php, worker/crons/toga2/compass/edi/1_download_edi_s3_create_po_toga.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php, worker/crons/toga2/compass/workflow/4_transmit_office_depot_po_acknowledgements.php, worker/crons/toga2/compass/workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php, worker/schedules/cron.worker.sync.json, library/app/client/compass.php |
|
|
21
24
|
| [Compass Order Lifecycle & Data-Integrity Invariants](workflows/order-lifecycle-and-data-integrity.md) | 2.0 | End-to-end map of how a Compass order flows through the `Client_Compass` (2.0) database and the **expected raw-data shape** at each link/ASN/IF level. | |
|
|
22
25
|
| [Comparing Compass Persona/Catalogue Effects across prod and dev-sandbox](workflows/persona-population-env-comparison.md) | 2.0 | How to prove what a persona/catalogue change actually does to **100% of Compass USA users**, by diffing production against dev-sandbox. | test/@Bala/generate_persona_test_data.py, test/@Bala/persona-refactor-full-user-export.sql, test/@Bala/TRUE-75705-persona-refactor-test-plan.md |
|
|
23
26
|
| [Compass USA Persona Refactor — 8-Phase Migration & Deploy Order (TRUE-75705)](workflows/persona-refactor-migration.md) | 2.0 | Splits Compass USA's catch-all persona **1 ("All")** into **Base** (the six printer bundles, held by *everyone* including Levy) and a new **Non-Levy** persona ( | dbchanges2/Client_Compass/2026-08-06 - PersonaRefactor.sql, dbchanges2/Client_Compass/2026-08-06 - PersonaRefactor-Verification.sql, worker2/Worker/Client/Compass/PeopleFile.php, test/@Bala/TRUE-75705-persona-refactor-test-plan.md |
|