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.
@@ -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-05
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-05
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-12
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-11
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. What a 400 lacks is a
60
- `Logs.Issue` row: **no alerting, no aggregation, no owner** (see
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, logged as a
63
- request, no Issue) and `_Exception_Business` (500, Sentry + a routed Issue). Do **not** reach for
64
- `_Exception_Business` merely to "make sure the rejection is recorded" — it already is.
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-10
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 —
@@ -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) — 22 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
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)_ — 56 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
- - **worker2** (Worker) — 48 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
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 |