toga-ai 1.0.773 → 1.0.774

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@
6
6
  | [Canon received-PO → NetSuite sales order sync](features/canon-received-po-netsuite-order-sync.md) | A 1.0 `worker` cron that reads Canon customer purchase orders out of the **Vision** databases and creates the matching **sales order in NetSuite**. | worker/crons/sync/sync_received_po.php, library/netsuitetoolkit/NSPHPClient.php |
7
7
  | [Compass MA Sales Order Exception Report](features/compass-ma-sales-order-exception-report.md) | A worker cron that emails operations the "Compass Refresh Exception Report" — Compass `MA%` sales orders whose corresponding Office Depot (ODP) sales order has | worker/crons/toga2/compass/workflow/7_generate_ma_sales_order_exception_report.php, worker/schedules/cron.worker.sync.json |
8
8
  | [Compass Manager Approval Reminder Emails (1.0 worker crons)](features/compass-manager-approval-reminder-emails.md) | Two 1.0 worker crons nag approvers about sales orders still waiting on a decision — one per Compass tenant. | worker/crons/toga2/compass/compass_email_reminders.php, worker/crons/toga2/compasscanada/compass_email_reminders.php, worker/schedules/cron.worker.sync.json, worker1.5/crons/toga2/compass/compass_email_reminders.php, worker1.5/schedules/cron.worker.json |
9
- | [Compass Partial In-Transit & Delivered Emails (per package)](features/compass-partial-in-transit-delivered-emails.md) | Compass USA and Compass Canada send a **per-package** in-transit email (and a matching delivered email) instead of one email listing the whole order. | worker/crons/toga2/compass/update_salesorder_status_from_odp.php, worker/crons/toga2/compass/send_delivered_email.php, worker/crons/toga2/compasscanada/update_salesorder_status_from_odp.php, worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php, worker/crons/toga2/compasscanada/send_delivered_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_delivered_email.php, library/app/client/compasscanada.php |
9
+ | [Compass Partial In-Transit & Delivered Emails (per package)](features/compass-partial-in-transit-delivered-emails.md) | Compass USA and Compass Canada send a **per-package** in-transit email (and a matching delivered email) instead of one email listing the whole order. | worker/crons/toga2/compass/update_salesorder_status_from_odp.php, worker/crons/toga2/compass/send_delivered_email.php, worker/crons/toga2/compasscanada/update_salesorder_status_from_g&t.php, worker/crons/toga2/compasscanada/send_delivered_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_delivered_email.php, library/app/client/compasscanada.php, worker/schedules/cron.worker.sync.json, worker2/Worker/Monitor/Compass.php, dbchanges2/Client_Compass/2026-09-03 - compass_intransit_email_backlog_backfill.sql |
10
10
  | [Elite TOGA 2.0 → TOGaDeskSupport Standalone Attachment Sync](features/elite-togadesk-attachment-sync.md) | `sync_togadesk_elite_attachments.php` is a standalone cron (every 5 minutes) that syncs file attachments from TOGA 2.0 into TOGaDeskSupport for Elite. | worker/crons/toga2/elite/sync_togadesk_elite_attachments.php, worker/crons/toga2/elite/test_sync_togadesk_elite_attachments.php |
11
11
  | [Forecast2 ↔ NetSuite Reconciliation & Trueup Tooling](features/forecast2-netsuite-reconciliation.md) | CLI tools to **audit** and **repair** drift between the production `Forecast` DB (core2) and NetSuite. | test/@dave/checker.php, worker2/Component/Forecast/SaleImport/SaleImport.php, test/@dave/looper.php, test/@dave/reconcile_netsuite_totals.php, test/@dave/fixer.php, tools/bin/forecast/fixer.php, tools/bin/forecast/checker.php, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.php, test/@dave/reconcile_drift_2023plus.php, test/@dave/probe_invoice_gap_2026.php, test/@dave/probe_creditmemo_gap_detail.php, test/@dave/trueup_open_orders.php, test/@dave/loop_trueup_open_orders.php, test/@dave/trueup_opportunities.php, test/@dave/probe_sales_gap_direct.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_profit_invoices.php, test/@dave/probe_profit_gap.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_open_orders.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/schedules/cron.worker.infrastructure.json |
12
12
  | [Forecast2 Supporting-Records Nightly Import (1.0 cron) — Customers & ConsolidatedCustomers](features/forecast2-supporting-records-import.md) | The nightly **1.0** pull that keeps the Forecast2 lookup/dimension tables (`Forecast.Accounts`, `Classifications`, `Customers`, `ConsolidatedCustomers`, `Employ | worker/crons/toga2/forecast2/import_supporting_records.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker/schedules/cron.worker.infrastructure.json, worker/.ebextensions/009_setup_phpini.config, worker/crons/sync/netsuite/netsuite_customers.php, library/app/api/netsuite/rest.php, library/app/model/forecast2/customer.php, library/app/model/forecast2/consolidatedcustomer.php |
@@ -6,18 +6,23 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-26
9
+ updated: 2026-09-04
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - worker/crons/toga2/compass/update_salesorder_status_from_odp.php
13
13
  - worker/crons/toga2/compass/send_delivered_email.php
14
- - worker/crons/toga2/compasscanada/update_salesorder_status_from_odp.php
15
- - worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php
14
+ - worker/crons/toga2/compasscanada/update_salesorder_status_from_g&t.php
16
15
  - worker/crons/toga2/compasscanada/send_delivered_email.php
17
16
  - worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php
18
17
  - worker/crons/toga2/compasscanada/workflow/test_partial_delivered_email.php
19
18
  - library/app/client/compasscanada.php
19
+ - worker/schedules/cron.worker.sync.json
20
+ - worker2/Worker/Monitor/Compass.php
21
+ - dbchanges2/Client_Compass/2026-09-03 - compass_intransit_email_backlog_backfill.sql
20
22
  related:
23
+ - ../../../clients/compass-usa/features/contact-email-resolution.md
24
+ - ../../../2.0/apps/worker2/features/tracking-status-refresh.md
25
+ - ../../../2.0/apps/worker2/features/oneuptime-worker2-monitoring.md
21
26
  - ../../../clients/compass-canada/features/french-order-email-localization.md
22
27
  - ../../../clients/compass-usa/features/order-fulfillment-status-per-line.md
23
28
  - ../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
@@ -34,18 +39,28 @@ and earlier packages). The dynamic HTML is injected into a stored `EmailTemplate
34
39
 
35
40
  ## Key files / entry points
36
41
 
37
- - `worker/crons/toga2/compass/update_salesorder_status_from_odp.php` — Compass USA prod cron.
38
- - `worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php`
39
- — Compass Canada prod cron.
42
+ - `worker/crons/toga2/compass/update_salesorder_status_from_odp.php` — Compass USA prod cron,
43
+ registered in `worker/schedules/cron.worker.sync.json` as **"Update Compass SalesOrder Status
44
+ From ODP CXML"**, schedule **`0 */4 * * *`** — it fires at 00/04/08/12/16/20 **US/Central**
45
+ (the prod DB timezone). Every threshold below (batch cap, staleness, monitor alarms) is
46
+ calibrated to that 4-hour cadence.
47
+ - `worker/crons/toga2/compasscanada/update_salesorder_status_from_g&t.php` — Compass Canada
48
+ prod cron, registered as "Update CompassCanada SalesOrder Status From G&T CXML".
49
+ **⚠ This filename contains `&` and therefore cannot run — see the gotcha below.** It was
50
+ renamed from `update_salesorder_status_from_odp.php` on 2026-09-03; the duplicate
51
+ `workflow/3_update_salesorder_status_from_grand_and_toy.php` was **deleted** in the same pass and
52
+ its stale `schedules/cron.worker.sync.json` entry removed.
40
53
  - Shared helper set in each cron: `getOrderFulfillmentData`, `buildItemRowsHtml`,
41
54
  `buildSectionHeaderHtml`, `buildPackageBlockHtml`, `buildTrackingUrl`, `buildOrderItemsHtml`.
42
55
 
43
56
  ## How it works
44
57
 
45
58
  1. **Driving query** finds shipped sales orders and their tracking numbers (`computedStatusSlug
46
- = 'shipped'`, `c_dtInTransitEmailSent IS NULL`, date cutoff) via the ASN chain
47
- (`AdvanceShippingNoticeItemUnits_TrackingNumbers`), one row per `TrackingNumbers.number`.
48
- 2. Per tracking number: skip if the carrier API already reports delivered.
59
+ = 'shipped'`, `c_dtInTransitEmailSent IS NULL`, rolling date window) via the ASN chain
60
+ (`AdvanceShippingNoticeItemUnits_TrackingNumbers`), one row per `TrackingNumbers.number`,
61
+ **newest first and capped** — see *Draining the candidate set* below.
62
+ 2. Per tracking number: skip if the shipment is already finished — checked first against the
63
+ stored `TrackingNumbers.status` (free), and only then with a live carrier call.
49
64
  3. **`getOrderFulfillmentData`** returns the order's packages (grouped by tracking number,
50
65
  ordered by `dtCreated`) and ordered items, sourced from the **`ItemFulfillmentItems`** line
51
66
  bridge (`ItemFulfillmentItems` → `ItemFulfillments` (scoped to the buyer SO) →
@@ -58,6 +73,73 @@ and earlier packages). The dynamic HTML is injected into a stored `EmailTemplate
58
73
  `App_Api_Toga2::send(..., 'POST', '/email-templates/sendEmail', $payload, [], true)`.
59
74
  6. On a successful (non-throwing) send, set `c_dtInTransitEmailSent = NOW()`.
60
75
 
76
+ ### Draining the candidate set: two-pass, newest-first, bounded
77
+
78
+ **This is the load-bearing part of the cron. Getting it wrong took Compass USA in-transit emails to
79
+ near zero for a week** (2026-08-27 → 2026-09-03: 130–190 a day before, then 7 / 1 / 6 / 0 / 1).
80
+ Customers were not told their order had shipped.
81
+
82
+ The original shape was: select **every** unstamped `TrackingNumbers` row since a hardcoded
83
+ `'2026-06-15'`, then call UPS/FedEx **live, one row at a time**. When a shipment turned out to be
84
+ already delivered the loop `continue`d **without stamping `c_dtInTransitEmailSent`** — so the same
85
+ row came back on the next run, forever. The candidate set grew to **2,379** and the run hit its
86
+ `App_Framework::cronInitialization(3600)` one-hour limit while still inside the June rows. It never
87
+ reached the day's new ASNs. Nothing errored; the cron just ran out of time on every tick.
88
+
89
+ Four changes fix it, and they are the pattern to copy for any *"poll an external API per row, then
90
+ mark the row done"* cron:
91
+
92
+ 1. **Two passes.** Pass 1 walks the candidates and **stamps** every already-finished row so it
93
+ retires from the candidate set permanently. Pass 2 sends. A row can never again be examined
94
+ forever without being retired.
95
+ 2. **Newest first, hard cap.** `ORDER BY MAX(TrackingNumbers.dtCreated) DESC` +
96
+ `LIMIT IN_TRANSIT_EMAIL_BATCH_SIZE` (**400**). Today's shipments are served before any backlog,
97
+ so a backlog can delay old mail but can never starve new mail.
98
+ 3. **Rolling window, not a fixed date.** `IN_TRANSIT_EMAIL_LOOKBACK_DAYS = 14` →
99
+ `dtCreated >= DATE_SUB(NOW(), INTERVAL 14 DAY)`. A hardcoded cutoff date guarantees an
100
+ ever-growing scan; a rolling window is self-limiting.
101
+ 4. **Trust the stored status before paying for a carrier call.** `isTrackingStatusFinal(?string)`
102
+ short-circuits when `TrackingNumbers.status` is already `DELIVERED` or `RETURNED_TO_SENDER`
103
+ (`TRACKING_FINAL_STATUSES`). worker2's
104
+ [centralized tracking-status refresh](../../../2.0/apps/worker2/features/tracking-status-refresh.md)
105
+ keeps that column current for every client, so this 1.0 cron should not re-ask the carrier about
106
+ a finished shipment.
107
+
108
+ ### Resolving the recipient: a `Users` row is NOT required (USA)
109
+
110
+ The To address originally came only from `Users.email`, via `INNER JOIN Users ON Users.contactId =
111
+ …`. **EDI-created MR orders never get a `Users` row**, so those orders were silently absent from
112
+ the driving query — 12 MR orders with shipped parcels were never emailed and never even showed up
113
+ as a backlog.
114
+
115
+ The USA cron now resolves the address in this precedence:
116
+
117
+ ```
118
+ LEFT OUTER JOIN Users
119
+ ON Users.contactId = ShippedSalesOrders.contactId
120
+ AND Users.email IS NOT NULL AND Users.email <> ''
121
+ INNER JOIN Contacts
122
+ ON Contacts.id = ShippedSalesOrders.contactId
123
+ LEFT OUTER JOIN ContactEmailAddresses
124
+ ON ContactEmailAddresses.id = Contacts.primaryContactEmailAddressId
125
+ AND ContactEmailAddresses.contactId = Contacts.id # ownership guard
126
+ …
127
+ IFNULL(Users.email, ContactEmailAddresses.emailAddress) AS contactEmail
128
+ ```
129
+
130
+ The same `IFNULL` resolves the display name, and the `WHERE` requires the resolved address to be
131
+ non-empty.
132
+
133
+ **The `ContactEmailAddresses.contactId = Contacts.id` predicate is not optional.** About 14% of
134
+ Compass contacts point at an email row owned by a *different* contact, so without it the cron mails
135
+ the wrong person — see
136
+ [Compass contact email resolution](../../../clients/compass-usa/features/contact-email-resolution.md).
137
+
138
+ **Regression-checked, not assumed.** Over 60 days and **4,419 SA tracking rows** the old and new
139
+ queries both select **4,416**: 0 newly included, 0 lost, resolved address and name identical on all
140
+ 4,416. The `Users` address always wins because it is first in the `IFNULL`, so the change is
141
+ strictly additive — it only reaches orders the old query could not see at all.
142
+
61
143
  ### Tracking-URL building (`buildTrackingUrl`)
62
144
 
63
145
  The "Track Shipment" button and each per-package link resolve in this precedence:
@@ -100,7 +182,22 @@ the tracking number as emailed (it retries next run).
100
182
 
101
183
  ## Client variations
102
184
 
103
- - **Compass USA** — English only; `update_salesorder_status_from_odp.php`.
185
+ - **Compass USA** — English only; `update_salesorder_status_from_odp.php`. Has the full
186
+ 2026-09-03 hardening: two-pass drain, 400-row cap, 14-day window, `isTrackingStatusFinal()`,
187
+ **and** the `Users` → `Contacts`/`ContactEmailAddresses` recipient fallback with the ownership
188
+ guard.
189
+ - **⚠ Canada is only PARTLY at parity (verified 2026-09-04).**
190
+ `compasscanada/update_salesorder_status_from_g&t.php` has the batching changes
191
+ (`IN_TRANSIT_EMAIL_BATCH_SIZE = 400`, `IN_TRANSIT_EMAIL_LOOKBACK_DAYS = 14`,
192
+ `ORDER BY MAX(TrackingNumbers.dtCreated) DESC`, `isTrackingStatusFinal()`) but **still resolves
193
+ the recipient with `INNER JOIN Users … Users.email AS contactEmail`** — no `Contacts` /
194
+ `ContactEmailAddresses` fallback and no ownership guard. Canada therefore still cannot email a
195
+ contact that has no `Users` row. Port the recipient block if Canada ever gets EDI-created MR
196
+ orders.
197
+ - **Canada keeps its own retry behaviour on a carrier throw.** It leaves the row **unstamped** when
198
+ the carrier lookup throws, so the next run retries it; only rows confirmed *final* are stamped.
199
+ That is deliberate and must survive any future parity pass — stamping on a throw would silently
200
+ drop the email.
104
201
  - **Compass Canada** — EN/FR by `UserGlobalSettings.settingId = 2` (FR when the value starts
105
202
  with `fr`); French section labels and a French (HTML-entity) status message; uses
106
203
  `htmlentities` in the section header builder and a 7-param French-aware `buildPackageBlockHtml`.
@@ -113,6 +210,33 @@ the tracking number as emailed (it retries next run).
113
210
 
114
211
  ## Gotchas / known issues
115
212
 
213
+ - **⚠ A cron filename containing `&` cannot run at all.** `worker/ebs/cron.worker.php:41` builds
214
+ the crontab command by plain unquoted concatenation
215
+ (`'php /var/www/html/crons/' . $cron->cron`), so cron reads `&` as *"background this and start a
216
+ new command"*: `update_salesorder_status_from_g&t.php` executes as
217
+ `…/update_salesorder_status_from_g` plus a stray `t.php`, and the real script never runs —
218
+ silently, on every tick. **The Canada cron is in this state as of 2026-09-04 and must be renamed
219
+ (e.g. `update_salesorder_status_from_gt.php`) with `schedules/cron.worker.sync.json` repointed.**
220
+ Never put `&` (or any shell metacharacter) in a cron filename. See
221
+ [tracing a 1.0 worker cron run](../workflows/tracing-a-worker-cron-run-in-production.md).
222
+ - **⚠ Invoice tracking numbers are NOT in scope and must never be counted as in-transit
223
+ backlog.** `POST /v2/invoice-tracking-numbers` creates `TrackingNumbers` rows bridged through the
224
+ **`InvoiceTrackingNumbers`** table — never the ASN chain. On 2026-09-03 alone, 98 were created
225
+ (11:15–11:40) and **zero** of them touch `AdvanceShippingNoticeItemUnits_TrackingNumbers`.
226
+ Anything that walks the ASN unit bridge (this cron, and the OneUptime queue monitor) correctly
227
+ ignores them. Their shape is distinctive: **`shippingCarrierId` set, `shippingMethodId` NULL**.
228
+ A naive `TrackingNumbers WHERE c_dtInTransitEmailSent IS NULL` count will read these as a backlog
229
+ that never drains.
230
+ - **Clear an in-transit backlog by STAMPING, not by emailing — and refresh statuses first.**
231
+ Decided 2026-09-03 while draining the outage backlog
232
+ (`dbchanges2/Client_Compass/2026-09-03 - compass_intransit_email_backlog_backfill.sql`):
233
+ `c_dtInTransitEmailSent` was stamped on **64,132** rows older than the 14-day window and **421**
234
+ in-window rows already on a final status, leaving **68** genuinely still moving. Sending an
235
+ "in transit" email for a June order that arrived two months ago confuses the customer, so old
236
+ rows retire silently. **Ordering matters:** let worker2 `Sync/TrackingNumbers` refresh any
237
+ recently-unparked rows **before** running the backfill, otherwise a row with a stale status gets
238
+ a wrong in-transit email. (A parked row holds a stale status indefinitely — see
239
+ [tracking-status refresh](../../../2.0/apps/worker2/features/tracking-status-refresh.md).)
116
240
  - **Canada now uses the `ItemFulfillmentItems` line bridge, not the ASN chain**, for
117
241
  `getOrderFulfillmentData` — same query as Compass USA. Canada was originally on the ASN chain
118
242
  because it had **zero `ItemFulfillmentItems`**; if that line-bridge data is not populated for a
@@ -138,7 +262,7 @@ the tracking number as emailed (it retries next run).
138
262
  language even though the mail goes to the **manager**. Fixed 2026-08-03.
139
263
  - **⚠ Each cron keeps its OWN duplicated copy of the 2.0 `_status` CASE — a standing divergence
140
264
  risk.** `compass/update_salesorder_status_from_odp.php`,
141
- `compasscanada/update_salesorder_status_from_odp.php` and
265
+ `compasscanada/update_salesorder_status_from_g&t.php` and
142
266
  `compass/workflow/test_partial_in_transit_email.php` each re-implement the model's status SQL as
143
267
  `computedStatusSlug`. Today that is safe by luck of scope: the copies only ever produce canceled /
144
268
  pendingApprovalUnknown / pendingFulfillment / shipped and they filter on
@@ -161,6 +285,27 @@ the tracking number as emailed (it retries next run).
161
285
 
162
286
  ## Change history
163
287
 
288
+ - 2026-09-04 — **Fixed the Compass USA in-transit email outage (2026-08-27 → 2026-09-03, 130–190
289
+ emails/day → near zero).** Root cause: already-delivered rows were `continue`d without stamping
290
+ `c_dtInTransitEmailSent`, so the candidate set grew to 2,379 and every run burned its
291
+ `cronInitialization(3600)` hour inside rows dating back to a hardcoded `'2026-06-15'`, never
292
+ reaching new ASNs. Fix: two-pass drain (stamp finished rows, then send), newest-first
293
+ `ORDER BY MAX(dtCreated) DESC` with `IN_TRANSIT_EMAIL_BATCH_SIZE = 400`, rolling
294
+ `IN_TRANSIT_EMAIL_LOOKBACK_DAYS = 14` replacing the fixed date, and `isTrackingStatusFinal()`
295
+ short-circuiting the carrier call on `DELIVERED`/`RETURNED_TO_SENDER` using the status worker2
296
+ `Sync/TrackingNumbers` already maintains. Also **widened the recipient**: `LEFT OUTER JOIN Users`
297
+ + `Contacts`/`ContactEmailAddresses` with an ownership guard and
298
+ `IFNULL(Users.email, ContactEmailAddresses.emailAddress)`, which recovered 12 EDI-created MR
299
+ orders that have no `Users` row (regression-checked: 4,416 of 4,419 SA rows selected by both old
300
+ and new queries, 0 gained, 0 lost, identical resolved address/name). Backlog cleared by
301
+ **stamping** 64,132 out-of-window + 421 final-status rows rather than emailing them. Recorded
302
+ that **invoice tracking numbers are not in scope** (bridged via `InvoiceTrackingNumbers`, never
303
+ the ASN chain) and that a **`&` in a cron filename stops the cron dead**. Canada got the batching
304
+ changes only (renamed to `update_salesorder_status_from_g&t.php`; duplicate
305
+ `workflow/3_update_salesorder_status_from_grand_and_toy.php` deleted); its recipient block is
306
+ **still `INNER JOIN Users`**. New OneUptime monitor `Monitor/Compass/InTransitEmailQueue` now
307
+ watches this queue — see
308
+ [OneUptime push-metric monitors](../../../2.0/apps/worker2/features/oneuptime-worker2-monitoring.md). (bala)
164
309
  - 2026-08-26 — Flagged the **duplicated `_status` CASE** in the three crons as a standing divergence
165
310
  risk after the 2.0 Compass fulfillment rule was rewritten. Verified the crons are unaffected: their
166
311
  copies only emit canceled / pendingApprovalUnknown / pendingFulfillment / shipped and filter on
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-09-01
9
+ updated: 2026-09-04
10
10
  owners: ["bala", "kyalamarthi"]
11
11
  files:
12
12
  - worker/ebs/cron.worker.php
@@ -170,7 +170,28 @@ to **2.0 only**. The 1.0 standard wins in `library/`, `worker/` and every other
170
170
  `library/app/systemmonitor/500error.php` uses `str_contains()`; that file is not a version
171
171
  signal, it is a latent bug.
172
172
 
173
+ ## Deploy-time trap: a `&` (or any shell metacharacter) in the cron FILENAME silently kills it
174
+ `ebs/cron.worker.php:41` builds each crontab line by plain string concatenation
175
+ (`'php /var/www/html/crons/' . $cron->cron`) with **no quoting and no escaping**. Cron then parses
176
+ that line as a shell command, so a `&` in the filename is read as *"run everything before this in
177
+ the background and start a new command."*
178
+
179
+ Worked example (found 2026-09-03): a developer renamed the Compass Canada status cron to
180
+ `crons/toga2/compasscanada/update_salesorder_status_from_g&t.php` and repointed
181
+ `schedules/cron.worker.sync.json` at it. The crontab line becomes two commands —
182
+ `php .../update_salesorder_status_from_g` (a file that does not exist) and a stray `t.php` — so the
183
+ real script **never executes**, on every tick, with no error anywhere. It looks exactly like
184
+ "the cron never fired".
185
+
186
+ **Rule: a cron filename must be plain `[a-z0-9_]` plus `.php`.** No `&`, no spaces, no `;`, `|`,
187
+ `$`, `(`, `)`, `*`, `?`, or quotes. The *display name* in `schedules/*.json` is free text and may
188
+ contain anything; only the `cron` path is shell-parsed.
189
+
173
190
  ## Change history
191
+ - 2026-09-04 — Added the **`&`-in-a-cron-filename trap**: `ebs/cron.worker.php:41` concatenates the
192
+ path into the crontab line unquoted, so a shell metacharacter splits the command and the script
193
+ never runs while leaving no trace. Found via the renamed Compass Canada
194
+ `update_salesorder_status_from_g&t.php`. (bala)
174
195
  - 2026-09-01 — Added **Step 6: check the email log, not the inbox** — a 1.0 cron's sends land in
175
196
  the **legacy** env's `Logs.Emails` (schema `Logs`, table `Emails`), never prod `Logs` or
176
197
  `Logs_<Client>`; gave the verification query, and noted that a missing row usually means the job
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-30
10
- owners: ["jcardinal"]
9
+ updated: 2026-09-04
10
+ owners: ["jcardinal", "bala"]
11
11
  files:
12
12
  - worker2/Worker/Monitor/Operations.php
13
13
  - _underscore/Database.php
@@ -111,6 +111,15 @@ matter are the **log database name** and whether the `Email` migration has been
111
111
 
112
112
  ## Gotchas / known issues
113
113
 
114
+ - **⚠ This monitor is blind to an email that was never queued.** It reads `Logs_<Client>.Email`,
115
+ so it can only see a send that already produced a row. If the producing cron dies, stalls or
116
+ never selects the work, this monitor reports a **healthy empty queue**. That is exactly what
117
+ happened during the Compass USA in-transit outage (2026-08-27 → 2026-09-03): the 1.0 sending
118
+ cron timed out before creating a single email row and this monitor stayed green all week. Pair
119
+ any email-queue monitor with a **backlog monitor on the producer's input** — first instance
120
+ `Monitor/Compass/InTransitEmailQueue`, see
121
+ [OneUptime push-metric monitors](./oneuptime-worker2-monitoring.md).
122
+
114
123
  - **A client's database name is NOT derivable from `clientIdentifier`.** `Compass_Usa` →
115
124
  `Logs_Compass`. Always resolve `Clients.logDatabaseId` → `Databases.name`. Building
116
125
  `'Logs_' . $clientIdentifier` silently targets a nonexistent database. See
@@ -150,6 +159,10 @@ matter are the **log database name** and whether the `Email` migration has been
150
159
  against framework source only, not against real rows.
151
160
 
152
161
  ## Change history
162
+ - 2026-09-04 — Recorded the monitor's **structural blind spot**: it only sees emails that reached
163
+ `Logs_<Client>.Email`, so a producer that never queues anything reads as healthy (proved by the
164
+ week-long Compass USA in-transit outage). Points at the new
165
+ `Monitor/Compass/InTransitEmailQueue` input-backlog monitor as the complement. (bala)
153
166
 
154
167
  - 2026-07-30 — Refactored `EmailQueue()` from Compass-only (hardcoded `Logs_Compass` via
155
168
  `initialize()` + `const DB_LOGS_COMPASS`, both removed) to an all-client fan-out
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-03
9
+ updated: 2026-09-04
10
10
  owners: ["jcardinal", "mhammontree", "bala"]
11
11
  files:
12
12
  - worker2/Worker/Monitor/Compass.php
@@ -23,6 +23,7 @@ files:
23
23
  - worker2/Config/sandbox-dev.ini
24
24
  related:
25
25
  - ./oneuptime-incident-clickup-sync.md
26
+ - ../../../1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md
26
27
  - ./monitoring-framework.md
27
28
  - ./all-client-email-queue-monitor.md
28
29
  - ../../_underscore/features/cloud-s3-helpers.md
@@ -36,7 +37,7 @@ A second, **OneUptime-reporting** monitoring pattern for the 2.0 worker2 tier, p
36
37
  the 1.0 `App_SystemMonitor_Compass` monitors. A worker2 cron action runs a self-contained
37
38
  check, decides pass/fail itself, and POSTs a JSON metric body to a OneUptime "Incoming
38
39
  Request" monitor — replacing the 1.0 tier's email alerts. The class is
39
- `abstract class _Worker_Monitor_Compass` and now carries a **suite of 9 monitors** covering
40
+ `abstract class _Worker_Monitor_Compass` and now carries a **suite of 10 monitors** covering
40
41
  the Compass USA / Office Depot integration (S3, DB, and mailbox checks). Designed to be
41
42
  **client-agnostic** — a reusable template for future monitors and clients.
42
43
 
@@ -59,7 +60,7 @@ that reports straight to OneUptime. Note the folder difference: these live under
59
60
  - `_underscore/Cloud.php` — `_Cloud::getS3Objects()` list helper the S3 monitors rely on
60
61
  (see [Cloud S3 helpers](../../_underscore/features/cloud-s3-helpers.md)).
61
62
 
62
- ## The 9 monitors
63
+ ## The 10 monitors
63
64
 
64
65
  Each measures one Compass/Office Depot integration metric and pushes a decided alarm token.
65
66
  - **OfficeDepotEdiImportQueue** (S3) — counts files >10 min old in
@@ -106,6 +107,11 @@ Each measures one Compass/Office Depot integration metric and pushes a decided a
106
107
  logic is superseded by this corrected definition.)
107
108
  - **OfficeDepotAs2Outbox** (S3) — counts ONLY top-level files in
108
109
  `s3://agilant-as2/OfficeDepot/OUTBOX/` (skips subfolders + directory placeholders).
110
+ - **InTransitEmailQueue** (DB, added 2026-09-04) — watches the Compass USA **in-transit
111
+ shipping-notification queue**: `TrackingNumbers` rows on the ASN unit bridge with
112
+ `c_dtInTransitEmailSent IS NULL` inside a 14-day window (the window the 1.0 sending cron uses).
113
+ Status-page name *"Shipping Notification Emails (USA, In Transit)"*. See
114
+ [why it exists](#watch-the-step-before-the-log-row-exists) below.
109
115
 
110
116
  ## How it works
111
117
 
@@ -185,12 +191,58 @@ its error messages are the only documentation, so the verified shape:
185
191
  - `incidentSeverityId` is wrapped: `{_type: "ObjectID", value: "…"}`.
186
192
  - There is **no `projectId`** in the export, and **status/severity ObjectIDs are
187
193
  project-specific** — clone them out of an existing monitor's export rather than inventing them.
194
+ - **⚠ Every description in the file is capped at 500 characters.** The importer rejects the whole
195
+ file with `description length cannot be more than 500 characters`. It applies to the **monitor**
196
+ description **and** to every **criteria** and **incident** description in the same file, so a
197
+ chatty runbook paragraph inside a criterion silently blocks the import. Keep each description
198
+ short and put the detail in the knowledge base.
188
199
 
189
200
  **Practical fix for the [activation-ordering trap](#gotchas--known-issues):** instead of
190
201
  importing the monitor twice (body criteria first, absence criteria after the first push), import
191
202
  the absence criteria **in the same file with `isEnabled: false`** and simply flip them on once a
192
203
  push has landed.
193
204
 
205
+ ### Watch the step BEFORE the log row exists
206
+
207
+ A queue monitor that reads a **log** table can only see work that already produced a row. The
208
+ all-client [Email Queue Monitor](./all-client-email-queue-monitor.md) watches
209
+ `Logs_<Client>.Email` for `FAILED` and stale `PENDING` — excellent once an email row exists, and
210
+ **structurally blind before that**. During the Compass USA in-transit outage (2026-08-27 →
211
+ 2026-09-03) the sending cron never got far enough to create a single email row, so that monitor
212
+ reported a perfectly healthy, empty queue for a week while customers were not being told their
213
+ orders had shipped.
214
+
215
+ **Rule: for any producer→log pipeline, monitor the producer's INPUT backlog, not only its output
216
+ log.** `InTransitEmailQueue` is the worked example — it counts unsent candidates in the source
217
+ table, so the alarm fires even when nothing downstream ever runs. Its thresholds
218
+ (`Monitor/Compass/InTransitEmailQueue`, `*/5 * * * *`):
219
+
220
+ | Signal | DEGRADED | HIGH |
221
+ |---|---|---|
222
+ | `queuedRows` (unsent, in-window) | > 400 | > 800 |
223
+ | `oldestQueuedHours` | > 5 | > 9 |
224
+ | `lastStampHoursAgo` while `queuedRows > 0` | — | > 9 |
225
+ | heartbeat not received | 10 min | 15 min |
226
+
227
+ The row thresholds come straight from the sending cron's `IN_TRANSIT_EMAIL_BATCH_SIZE = 400`
228
+ (past 400 it cannot catch up in one pass; 800 is two passes behind) and the hour thresholds from its
229
+ `0 */4 * * *` schedule (worst normal wait is just under 4h, so 5h means a shipment missed its run
230
+ and 9h means two). This is the *"calibrate against the tier that runs the business logic"* rule
231
+ applied literally — see below. A DB read failure pushes `"status":"error"` so "we cannot see"
232
+ never reads as healthy.
233
+
234
+ **It reports `unroutableRows` (candidates with no emailable recipient) as a plain number that
235
+ deliberately does not drive the alarm** — otherwise the monitor would sit red forever on rows the
236
+ cron structurally cannot send. That is the *"backlog monitors must exclude known-permanently-stranded
237
+ rows"* rule.
238
+
239
+ > **⚠ Known divergence (2026-09-04):** the monitor decides routable/unroutable from the
240
+ > `Users` join **only**, while the sending cron now also falls back to
241
+ > `Contacts` / `ContactEmailAddresses`. So some rows counted as `unroutableRows` are in fact
242
+ > sendable, and they are excluded from `queuedRows`. Port the cron's recipient fallback into the
243
+ > monitor query to bring the two definitions back together — see
244
+ > [Compass in-transit emails](../../../1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md).
245
+
194
246
  ### Heartbeat / cron cadence timing
195
247
 
196
248
  The 10-min-Degraded / 15-min-Offline heartbeat thresholds pair with a **5-minute** cron
@@ -317,9 +369,9 @@ Testing **recovery** is the step people skip and the most valuable one:
317
369
  ### CronJob scheduling for these monitors
318
370
 
319
371
  Run **all hours**, `*/5 * * * *` (every 5 min) — **not** the 1.0 `6-20` business-hours
320
- window. Integration backlogs must be caught outside business hours too. The nine
321
- `Monitor/Compass/*` actions are `Core.CronJobs` **ids 27–35** (EmailedAsnImport = id 31),
322
- each `maxExecutionTime` **120s** — the reason any in-run retry budget must stay well under 120s.
372
+ window. Integration backlogs must be caught outside business hours too. The original nine
373
+ `Monitor/Compass/*` actions are `Core.CronJobs` **ids 27–35** (EmailedAsnImport = id 31), with
374
+ `InTransitEmailQueue` added on the same `*/5` cadence, each `maxExecutionTime` **120s** — the reason any in-run retry budget must stay well under 120s.
323
375
 
324
376
  ## DB monitor conventions
325
377
 
@@ -438,6 +490,16 @@ check for another client.
438
490
  monitors must pass the region (see [Cloud S3 helpers](../../_underscore/features/cloud-s3-helpers.md)).
439
491
 
440
492
  ## Change history
493
+ - 2026-09-04 — Added a **10th monitor, `InTransitEmailQueue`** (Compass USA shipping-notification
494
+ backlog: unsent `TrackingNumbers` on the ASN unit bridge inside the cron's 14-day window;
495
+ DEGRADED >400 rows / >5h oldest, HIGH >800 rows / >9h oldest / >9h with nothing stamped while
496
+ rows wait; thresholds derived from the 1.0 cron's 400-row cap and `0 */4 * * *` schedule;
497
+ `unroutableRows` reported but never alarmed; DB failure pushes `status:"error"`). Wrote up the
498
+ general lesson it embodies — **monitor the producer's input backlog, not only its output log** —
499
+ after the all-client Email Queue Monitor showed a healthy empty queue through a week-long outage
500
+ in which no email row was ever created. Recorded the **500-character cap on every description**
501
+ in a OneUptime resource-import file (monitor, criteria and incident descriptions alike), and the
502
+ monitor-vs-cron `unroutableRows` divergence. (bala)
441
503
  - 2026-09-03 — **Fixed a false HIGH age alarm** in `CompassSalesOrderTransmission()`.
442
504
  `oldestPendingAgeMinutes` was measured from **stage-2 approval only**
443
505
  (`TIMESTAMPDIFF(MINUTE, ApprovalDecisionsStage2.dtDecision, NOW())`), but stage 2 is **not**
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-24
9
+ updated: 2026-09-04
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - worker2/Worker/Sync/TrackingNumbers.php
@@ -169,6 +169,42 @@ to slug **`beta`** or **`sandbox-dev`**, **not `dev`**, and those enumerate a **
169
169
  client set than `dev`. Confirm the box's actual `ENVIRONMENT` value before trusting the dispatcher's
170
170
  client enumeration on beta.
171
171
 
172
+ ### ⚠ A PARKED row holds a STALE status forever — and downstream code now trusts that status
173
+
174
+ A row parked after repeated carrier failures gets `c_dtTrackingParked` /
175
+ `c_trackingParkReason = 'MAX_FAILURES'` (20 failed lookups) and its `c_dtTrackingCheckNext` cursor
176
+ stops being advanced, so **the row is never re-checked and `TrackingNumbers.status` freezes at
177
+ whatever it last was** — typically an early `IN_TRANSIT`. Parking is silent: nothing distinguishes
178
+ a stale status from a fresh one at the point of read.
179
+
180
+ Measured 2026-09-03: **189 Compass rows created 2026-08-20 → 2026-08-29 were parked on
181
+ `MAX_FAILURES`.** Unparking restores them:
182
+
183
+ ```sql
184
+ UPDATE TrackingNumbers
185
+ SET
186
+ c_dtTrackingParked = NULL,
187
+ c_trackingParkReason = NULL,
188
+ c_trackingCheckFailures = 0,
189
+ c_dtTrackingCheckNext = NOW()
190
+ WHERE
191
+ c_trackingParkReason = 'MAX_FAILURES'
192
+ ```
193
+
194
+ The refresh cron is **`Core.CronJobs` id 69, action `Sync/TrackingNumbers/Refresh`, every 10
195
+ minutes**, so the unparked rows pick up a real status within a couple of ticks.
196
+
197
+ **Why this now matters more than it used to.** The 1.0 Compass in-transit email cron short-circuits
198
+ its carrier call when `TrackingNumbers.status` is already `DELIVERED` / `RETURNED_TO_SENDER`, i.e.
199
+ it **trusts this column**. A parked row therefore keeps a shipment in the email queue (or, in the
200
+ opposite direction, can drive a wrong decision) with no visible error. **Before any bulk operation
201
+ that reads `TrackingNumbers.status` — a backfill, a reconciliation, a mass stamp — unpark and let
202
+ the refresh catch up first.** See
203
+ [Compass in-transit emails](../../../1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md).
204
+
205
+ Open question: nothing today re-tries or reports parked rows. A periodic unpark (or a monitor on
206
+ parked-row count) is unbuilt work.
207
+
172
208
  ### ⚠ Conflict with the 1.0 Compass delivered-email cron
173
209
  The in-transit / delivered emails **today** come from a separate **1.0** Compass worker cron
174
210
  (`worker/crons/toga2/compass/send_delivered_email.php`) that polls UPS itself, sends via api2
@@ -190,6 +226,14 @@ Plan (not built): move the email logic into a Compass `postPut` interceptor and
190
226
  check, and **resolve the PHP-8.5 worker → api2 crash** (the gate).
191
227
 
192
228
  ## Change history
229
+ - 2026-09-04 — Recorded that **parked rows hold a stale status indefinitely**: 189 Compass rows
230
+ (created 2026-08-20 → 08-29) sat on `c_trackingParkReason = 'MAX_FAILURES'` after 20 failed
231
+ carrier lookups, with `c_dtTrackingCheckNext` no longer advancing, so `TrackingNumbers.status`
232
+ froze silently. Added the unpark recipe and noted the cron identity (`Core.CronJobs` id 69,
233
+ `Sync/TrackingNumbers/Refresh`, `*/10`). This got sharper because the 1.0 Compass in-transit
234
+ email cron now **trusts** `TrackingNumbers.status` to skip a carrier call, so any bulk read of
235
+ that column must unpark and let the refresh catch up first. No periodic unpark or parked-row
236
+ monitor exists yet. (bala)
193
237
  - 2026-08-24 — Built the centralized tracking-status refresh: dispatcher + per-client child
194
238
  (`_Worker_Sync_TrackingNumbers`), per-row `c_dtTrackingCheckNext` re-check cursor (+1h imminent /
195
239
  +6h else, jitter, terminal → NULL, 45-day cutoff), `PAGE_CAP=50`/`SOFT_TIME_BUDGET_SECONDS=40`
@@ -18,7 +18,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
18
18
 
19
19
  ## 2.0 framework
20
20
 
21
- - **_underscore** (_Underscore) _(framework core)_ — 80 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
+ - **_underscore** (_Underscore) _(framework core)_ — 81 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 59 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 25 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 15 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -3,8 +3,8 @@
3
3
  | Doc | Framework | Summary | Files |
4
4
  |-----|-----------|---------|-------|
5
5
  | [French (fr-CA) Item Feature Translations (Compass Canada)](features/french-item-feature-translations.md) | 2.0 | Renders item **feature** text on the Compass Canada French storefront — feature names, feature-group headers (e.g. | _underscore/Model/Compass/Canada/Feature.php, _underscore/Model/Compass/Canada/ItemCategoryFeatureGroup.php, _underscore/Model/Client/FeatureTranslation.php, _underscore/Model/Client/ItemCategoryFeatureGroupTranslation.php, _underscore/Model/Client/ItemFeatureTranslation.php, worker2/Worker/Etilize/ItemTranslations.php, dbchanges2/Client/2026-07-13a - FeatureTranslations.sql, dbchanges2/Client/2026-07-13b - FeatureTranslationsAcl.sql, dbchanges2/Core/2026-07-13 - FeatureTranslationsRecord.sql, dbchanges2/Client_CompassCanada/2026-07-13 - FeatureAttributeCustomFields.sql, dbchanges2/Client_CompassCanada/2026-07-13 - DedupeItemFeaturesAndGroups.sql, dbchanges2/Client_CompassCanada/2026-07-13 - SeedFrenchFeatureTranslations.sql |
6
- | [French (fr-CA) Order Email Localization (Compass Canada)](features/french-order-email-localization.md) | 2.0 | Compass Canada order emails (order requested, manager-approval request, approved/rejected, in-transit, delivered, reminders) are sent in **each recipient's own | _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/ApprovalDecision.php, _underscore/Component/Api/Togaiq/Togaiq.php, _underscore/String.php, library/app/client/compasscanada.php, worker/crons/toga2/compasscanada/send_delivered_email.php, worker/crons/toga2/compasscanada/compass_email_reminders.php, worker/crons/toga2/compasscanada/compass_cancel_pending_approval_orders.php, worker/crons/toga2/compasscanada/update_salesorder_status_from_odp.php, worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php, worker/crons/toga2/compasscanada/workflow/4_import_grand_and_toy_advance_shipping_notices.php, worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_delivered_email.php, worker/crons/toga2/compasscanada/workflow/test_email_previews_prod.php |
7
- | [Grand & Toy ASN Import (Compass Canada)](features/grand-and-toy-asn-import.md) | 2.0 | Imports Grand & Toy (G&T) Advance Shipping Notices for Compass Canada. | api2/Component/Api/Cxml/Cxml.php, worker/crons/toga2/compasscanada/workflow/4_import_grand_and_toy_advance_shipping_notices.php, worker/crons/toga2/compasscanada/workflow/import_grand_and_toy_asn_from_file.php, worker/crons/toga2/compasscanada/workflow_beta/import_grand_and_toy_asn_from_file.php, worker/schedules/cron.worker.sync.json, _underscore/Model/Compass/AdvanceShippingNotice.php, _underscore/Model/Compass/Canada/AdvanceShippingNotice.php, dbchanges2/Client_CompassCanada/ |
6
+ | [French (fr-CA) Order Email Localization (Compass Canada)](features/french-order-email-localization.md) | 2.0 | Compass Canada order emails (order requested, manager-approval request, approved/rejected, in-transit, delivered, reminders) are sent in **each recipient's own | _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/ApprovalDecision.php, _underscore/Component/Api/Togaiq/Togaiq.php, _underscore/String.php, library/app/client/compasscanada.php, worker/crons/toga2/compasscanada/send_delivered_email.php, worker/crons/toga2/compasscanada/compass_email_reminders.php, worker/crons/toga2/compasscanada/compass_cancel_pending_approval_orders.php, worker/crons/toga2/compasscanada/update_salesorder_status_from_g&t.php, worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php, worker/crons/toga2/compasscanada/workflow/3_import_grand_and_toy_advance_shipping_notices.php, worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_delivered_email.php, worker/crons/toga2/compasscanada/workflow/test_email_previews_prod.php |
7
+ | [Grand & Toy ASN Import (Compass Canada)](features/grand-and-toy-asn-import.md) | 2.0 | Imports Grand & Toy (G&T) Advance Shipping Notices for Compass Canada. | api2/Component/Api/Cxml/Cxml.php, worker/crons/toga2/compasscanada/workflow/3_import_grand_and_toy_advance_shipping_notices.php, worker/crons/toga2/compasscanada/workflow/import_grand_and_toy_asn_from_file.php, worker/crons/toga2/compasscanada/workflow_beta/import_grand_and_toy_asn_from_file.php, worker/schedules/cron.worker.sync.json, _underscore/Model/Compass/AdvanceShippingNotice.php, _underscore/Model/Compass/Canada/AdvanceShippingNotice.php, dbchanges2/Client_CompassCanada/ |
8
8
  | [Compass Canada — Item-Fulfillment TableViews (dead-Core-id 500, rebuilt onto the USA shape)](features/item-fulfillment-tableviews.md) | 2.0 | `Client_CompassCanada`'s two item-fulfillment table views — **`item-fulfillments-for-sales-orders`** (`TableViews.id 12`) and **`item-fulfillments-for-sales-ord | dbchanges2/Client_CompassCanada/2026-08-27a - ItemFulfillmentTableViewsRebuild.sql, dbchanges2/Client_CompassCanada/2026-08-26 - EnableTableViewFieldFilters.sql, _underscore/Model/Client/TableView.php |
9
9
  | [Compass Canada — Fulfilled vs Partially Fulfilled (per-line, ASN bridge only)](features/order-fulfillment-status-per-line.md) | 2.0 | TOGa Supply showed **Fulfilled** on Compass Canada orders that were only partly shipped. | _underscore/Model/Compass/Canada/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php |
10
10
  | [Compass Canada](profile.md) | 2.0 | Compass Canada is the Canadian arm of the Compass account — a separate TOGA tenant, related to but distinct from Compass USA. | |
@@ -16,9 +16,9 @@ files:
16
16
  - worker/crons/toga2/compasscanada/send_delivered_email.php
17
17
  - worker/crons/toga2/compasscanada/compass_email_reminders.php
18
18
  - worker/crons/toga2/compasscanada/compass_cancel_pending_approval_orders.php
19
- - worker/crons/toga2/compasscanada/update_salesorder_status_from_odp.php
19
+ - worker/crons/toga2/compasscanada/update_salesorder_status_from_g&t.php
20
20
  - worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php
21
- - worker/crons/toga2/compasscanada/workflow/4_import_grand_and_toy_advance_shipping_notices.php
21
+ - worker/crons/toga2/compasscanada/workflow/3_import_grand_and_toy_advance_shipping_notices.php
22
22
  - worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php
23
23
  - worker/crons/toga2/compasscanada/workflow/test_partial_delivered_email.php
24
24
  - worker/crons/toga2/compasscanada/workflow/test_email_previews_prod.php
@@ -9,7 +9,7 @@ updated: 2026-08-20
9
9
  owners: ["bala"]
10
10
  files:
11
11
  - api2/Component/Api/Cxml/Cxml.php
12
- - worker/crons/toga2/compasscanada/workflow/4_import_grand_and_toy_advance_shipping_notices.php
12
+ - worker/crons/toga2/compasscanada/workflow/3_import_grand_and_toy_advance_shipping_notices.php
13
13
  - worker/crons/toga2/compasscanada/workflow/import_grand_and_toy_asn_from_file.php
14
14
  - worker/crons/toga2/compasscanada/workflow_beta/import_grand_and_toy_asn_from_file.php
15
15
  - worker/schedules/cron.worker.sync.json
@@ -65,7 +65,7 @@ serial numbers and the reference for the field mapping.
65
65
  > non-prod patch also evaporates on the next tenant reseed — the same lesson as AIG TRUE-80562.)
66
66
 
67
67
  ## Key files / entry points
68
- - `worker/crons/toga2/compasscanada/workflow/4_import_grand_and_toy_advance_shipping_notices.php`
68
+ - `worker/crons/toga2/compasscanada/workflow/3_import_grand_and_toy_advance_shipping_notices.php`
69
69
  — the scheduled cron (1.0 worker tier). Reads mailbox `compasscanada.status@togatech.com`
70
70
  (OAuth2 creds — currently hardcoded with a TODO; must move to `App_Registry::get('config')['compasscanada']['gt_asn_oauth2']`
71
71
  before production), parses the 24-column CSV, posts to api2 `/advance-shipping-notices`.
@@ -160,7 +160,7 @@ them correctly read **pendingFulfillment**.
160
160
  - **The badge is display-only and never stored.** The status is computed live, so there is no
161
161
  backfill and reverting the code fully reverts the behaviour. **No cron, email or report reads the
162
162
  fulfilled vs partiallyFulfilled distinction.** Canada's in-transit email cron
163
- (`worker/crons/toga2/compasscanada/update_salesorder_status_from_odp.php`) keeps its **own
163
+ (`worker/crons/toga2/compasscanada/update_salesorder_status_from_g&t.php`) keeps its **own
164
164
  duplicated copy** of the `_status` CASE, but it only ever produces canceled /
165
165
  pendingApprovalUnknown / pendingFulfillment / shipped and filters on
166
166
  `computedStatusSlug = 'shipped'`. Note the Canada pendingFulfillment gate is **no longer
@@ -4,6 +4,7 @@
4
4
  |-----|-----------|---------|-------|
5
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, _underscore/Model/Quad/SalesOrder.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
+ | [Compass Contact Email Resolution & Crossed `primaryContactEmailAddressId` Pointers](features/contact-email-resolution.md) | 2.0 | **Any Compass code that reads a contact's email address must also assert that the email row belongs to that contact.** Verified in prod on 2026-09-03: **42,926 | _underscore/Model/Compass/PurchaseOrder.php, worker/crons/toga2/compass/update_salesorder_status_from_odp.php |
7
8
  | [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
9
  | [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, dbchanges2/Core/2026-07-17 - RegisterItemIsFulfillableInterceptors.sql, toga2-supply/src/pages/Orders/view/OrderView/components/sections/OrderItemsTableSection.tsx |
9
10
  | [Compass Item Catalogs (Compass vs Agilant) & the Duplicate Items the NetSuite Sync Creates](features/item-catalogs-and-duplicate-items.md) | 1.0 | `Client_Compass.Items` is **split across catalogs by which pipeline created the item**, and the two halves are routinely mistaken for one. | library/app/api/toga2.php, _underscore/Model/Compass/PurchaseOrder.php, _underscore/Model/Compass/Usa/PurchaseOrder.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php, worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php |
@@ -0,0 +1,149 @@
1
+ ---
2
+ title: Compass Contact Email Resolution & Crossed `primaryContactEmailAddressId` Pointers
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: compass-usa
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-09-04
10
+ owners: ["bala"]
11
+ files:
12
+ - _underscore/Model/Compass/PurchaseOrder.php
13
+ - worker/crons/toga2/compass/update_salesorder_status_from_odp.php
14
+ related:
15
+ - ./mits-po-to-so-item-linking.md
16
+ - ./people-file-user-lifecycle.md
17
+ - ../profile.md
18
+ - ../../../1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md
19
+ ---
20
+
21
+ ## Summary
22
+
23
+ **Any Compass code that reads a contact's email address must also assert that the email row
24
+ belongs to that contact.** Verified in prod on 2026-09-03: **42,926 of 313,660** Compass contacts
25
+ (**~14%**) have `Contacts.primaryContactEmailAddressId` pointing at a `ContactEmailAddresses` row
26
+ whose `contactId` is a **different** contact. Read the pointer without that guard and you mail the
27
+ wrong human.
28
+
29
+ A second, related defect sits one level up: on **MR** orders the sales order itself is sometimes
30
+ attached to the **wrong contact**, while the MITS payload named the right person all along. So when
31
+ a Compass contact email "looks wrong" on an MR order, the usual truth is *the email is correct and
32
+ the SO-to-contact link is broken* — check the link before you touch any email data.
33
+
34
+ ## The correct way to read a Compass contact's email
35
+
36
+ ```sql
37
+ INNER JOIN Contacts
38
+ ON Contacts.id = <someRecord>.contactId
39
+ LEFT OUTER JOIN ContactEmailAddresses
40
+ ON ContactEmailAddresses.id = Contacts.primaryContactEmailAddressId
41
+ AND ContactEmailAddresses.contactId = Contacts.id # <- the ownership guard
42
+ ```
43
+
44
+ The `AND ContactEmailAddresses.contactId = Contacts.id` predicate is the whole point. Without it
45
+ the join happily follows a pointer into another contact's mailbox. First production consumer to
46
+ carry the guard: the Compass USA in-transit email cron
47
+ (`worker/crons/toga2/compass/update_salesorder_status_from_odp.php`) — see
48
+ [Compass partial in-transit & delivered emails](../../../1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md).
49
+
50
+ Note the guard makes the join *fail closed*: a crossed contact resolves to **no** address rather
51
+ than to a wrong one. That is the desired trade — a missing email is a visible gap, a wrong email is
52
+ a customer-facing incident.
53
+
54
+ ## Why the pointers are crossed
55
+
56
+ The Compass tenant carries a large population of **duplicate contacts** created by the MITS
57
+ purchase-order import (e.g. **"Ben Berdan" exists 4 times**). Duplicates are created in adjacent id
58
+ ranges, and the pointer/owner pairing drifts between them.
59
+
60
+ The mechanism is visible in `_Model_Compass_PurchaseOrder::prePost()` (`handleMrOrder` path,
61
+ `_underscore/Model/Compass/PurchaseOrder.php` ~lines 72–105). Before creating the SO it resolves the
62
+ contact like this:
63
+
64
+ ```php
65
+ $api->internalApiRequest('GET', '/contacts', null, [
66
+ 'join' => [['ContactEmailAddresses' => ['ContactEmailAddresses.id' => 'Contacts.primaryContactEmailAddressId']]],
67
+ 'where' => ['and' => [['ContactEmailAddresses.emailAddress' => ['=' => $contactEmail]]]],
68
+ 'recordsPerPage' => 1,
69
+ ]);
70
+ // no match -> POST /contacts creates a brand-new contact
71
+ ```
72
+
73
+ Three properties of that lookup matter:
74
+
75
+ 1. **No ownership guard** — it joins on `ContactEmailAddresses.id = Contacts.primaryContactEmailAddressId`
76
+ only, so a contact that *points at* someone else's email row is a valid match for that email.
77
+ 2. **`recordsPerPage 1` with no `ORDER BY`** — when several contacts match, which one comes back is
78
+ arbitrary and can change between calls.
79
+ 3. **Miss → create** — a lookup that fails to see the existing person creates yet another duplicate,
80
+ which feeds the next round.
81
+
82
+ ## Consequence: MR sales orders linked to the wrong contact
83
+
84
+ The MITS purchase-order payload names the real person in `contact._name` and
85
+ `contact.primaryContactEmailAddress.emailAddress`, but the SO ends up attached to a **neighbouring
86
+ contact id**. Confirmed cases (prod, 2026-09-03):
87
+
88
+ | MR order | SO is linked to | MITS payload actually named |
89
+ |---|---|---|
90
+ | MR245526 | contact **205042** "Luis Cervantes" | "Rusty Evans" / `Rusty.Evans@compass-usa.com` = contact **205041** |
91
+ | MR245513 | Eric Campbell | Benjamin Berdan |
92
+ | MR245463, MR244893, MR244699, MR245221 | same shape | — |
93
+
94
+ Note the adjacent ids (205041 / 205042) — the hallmark of duplicates created together by the import.
95
+
96
+ **Triage rule: the payload is the source of truth.** `Logs_Compass.Api` holds the inbound MITS
97
+ `POST /v2/purchase-orders` body; compare `contact.primaryContactEmailAddress.emailAddress` there
98
+ against the SO's linked contact before concluding the email data is wrong.
99
+
100
+ **10 such orders were deliberately left unfixed pending business input** — repointing a shipped SO
101
+ to a different person is a business decision, not a data cleanup.
102
+
103
+ ## The 2026-09-03 data fix (32 safe MR cases)
104
+
105
+ `compass_mr_contact_email_ownership_2026-09-03.sql` — run in prod, all 32 verified. The safe repair
106
+ shape, for the cases where the crossed contact and the owner hold the **same address**:
107
+
108
+ 1. Insert a `ContactEmailAddresses` row **owned by the crossed contact**, carrying the same
109
+ `emailAddress`.
110
+ 2. Repoint that contact's `Contacts.primaryContactEmailAddressId` at its own new row.
111
+
112
+ Nobody's address changes; each contact simply stops borrowing another contact's row. This is only
113
+ valid when the two addresses already match — a differing address is a real identity question and
114
+ must not be auto-resolved.
115
+
116
+ The remaining ~42,900 crossed contacts were **not** mass-repaired. Consumers carry the ownership
117
+ guard instead, which is safe today; a bulk cleanup is still open work.
118
+
119
+ ## Gotchas / known issues
120
+
121
+ - **⚠ The pointer is crossed on ~14% of Compass contacts — treat an unguarded read as a bug.**
122
+ This applies to reports, exports, notification crons, api2 payload interceptors, anything.
123
+ - **A `Users` row is a different lookup and does not have this problem.** `Users.email` is keyed by
124
+ `Users.contactId` directly. Where both exist, prefer `Users.email` and fall back to the guarded
125
+ `ContactEmailAddresses` read — that is the `IFNULL(Users.email, ContactEmailAddresses.emailAddress)`
126
+ precedence used by the in-transit cron. EDI-created MR orders have **no** `Users` row at all,
127
+ which is why the fallback exists.
128
+ - **`_Model_Compass_PurchaseOrder`'s contact lookup still has no ownership guard** (as of
129
+ 2026-09-04). Fixing it there stops *new* crossings; the existing 42,926 remain either way.
130
+ - **Compass Canada shares the model** (`Model/Compass/Canada/PurchaseOrder.php` inherits the parent),
131
+ so the same lookup shape runs against `Client_CompassCanada`. Not measured there.
132
+ - Duplicate-contact creation and duplicate-**user** creation are separate problems with separate
133
+ causes — the `Users` duplicates come from the PEOPLE-file reactivation window, see
134
+ [PEOPLE-file user lifecycle](./people-file-user-lifecycle.md).
135
+
136
+ ## Change history
137
+
138
+ - 2026-09-04 — Documented that **42,926 / 313,660 (~14%) Compass contacts have
139
+ `Contacts.primaryContactEmailAddressId` pointing at another contact's `ContactEmailAddresses`
140
+ row** (verified prod 2026-09-03), and established the mandatory
141
+ `ContactEmailAddresses.contactId = Contacts.id` ownership guard for every email read. Traced the
142
+ cause to `_Model_Compass_PurchaseOrder`'s MR contact lookup (joins on the pointer only, no
143
+ ownership guard, `recordsPerPage 1` with no `ORDER BY`, miss creates a duplicate) and to the
144
+ resulting duplicate contacts from the MITS PO import. Recorded the related defect that **MR sales
145
+ orders can be linked to the wrong contact** while the MITS payload named the right person
146
+ (MR245526, MR245513, MR245463, MR244893, MR244699, MR245221), with the triage rule to trust the
147
+ logged payload. Fixed 32 safe MR cases via
148
+ `compass_mr_contact_email_ownership_2026-09-03.sql` (give each contact its own email row with the
149
+ same address, then repoint); 10 ambiguous orders left for business input. (bala)
@@ -204,7 +204,7 @@ Re-confirmed 2026-08-27: **no cron, email or report reads the distinction.**
204
204
  behaviour.
205
205
  - **In-transit / delivered email crons are unaffected.**
206
206
  `worker/crons/toga2/compass/update_salesorder_status_from_odp.php`,
207
- `worker/crons/toga2/compasscanada/update_salesorder_status_from_odp.php` and
207
+ `worker/crons/toga2/compasscanada/update_salesorder_status_from_g&t.php` and
208
208
  `compass/workflow/test_partial_in_transit_email.php` each keep their **own duplicated copy** of the
209
209
  `_status` CASE, but those copies only ever produce canceled / pendingApprovalUnknown /
210
210
  pendingFulfillment / shipped and they filter on `computedStatusSlug = 'shipped'`. **That
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.773",
3
+ "version": "1.0.774",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",