toga-ai 1.0.773 → 1.0.775

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.
Files changed (30) hide show
  1. package/knowledge/1.0/apps/webhook/INDEX.md +1 -1
  2. package/knowledge/1.0/apps/webhook/features/netsuite-clickup-opportunity-sync.md +50 -15
  3. package/knowledge/1.0/apps/worker/INDEX.md +2 -2
  4. package/knowledge/1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md +156 -11
  5. package/knowledge/1.0/apps/worker/features/forecast2-netsuite-reconciliation.md +62 -2
  6. package/knowledge/1.0/apps/worker/workflows/tracing-a-worker-cron-run-in-production.md +22 -1
  7. package/knowledge/2.0/apps/_underscore/INDEX.md +1 -1
  8. package/knowledge/2.0/apps/_underscore/architecture.md +18 -3
  9. package/knowledge/2.0/apps/_underscore/features/apirequest-json-content-type.md +39 -1
  10. package/knowledge/2.0/apps/_underscore/features/forecast-sale-import.md +23 -2
  11. package/knowledge/2.0/apps/_underscore/features/netsuite-rest-client.md +28 -2
  12. package/knowledge/2.0/apps/api2/features/v2-deadlock-retry.md +35 -2
  13. package/knowledge/2.0/apps/dbchanges2/architecture.md +3 -2
  14. package/knowledge/2.0/apps/worker2/INDEX.md +4 -2
  15. package/knowledge/2.0/apps/worker2/features/all-client-email-queue-monitor.md +15 -2
  16. package/knowledge/2.0/apps/worker2/features/clickup-project-routing.md +85 -3
  17. package/knowledge/2.0/apps/worker2/features/netsuite-clickup-opportunity-bidirectional-design.md +155 -0
  18. package/knowledge/2.0/apps/worker2/features/netsuite-opportunity-sync.md +147 -25
  19. package/knowledge/2.0/apps/worker2/features/oneuptime-worker2-monitoring.md +68 -6
  20. package/knowledge/2.0/apps/worker2/features/tracking-status-refresh.md +45 -1
  21. package/knowledge/2.0/apps/worker2/workflows/clickup-credential-rotation.md +142 -0
  22. package/knowledge/INDEX.md +2 -2
  23. package/knowledge/clients/compass-canada/INDEX.md +2 -2
  24. package/knowledge/clients/compass-canada/features/french-order-email-localization.md +2 -2
  25. package/knowledge/clients/compass-canada/features/grand-and-toy-asn-import.md +2 -2
  26. package/knowledge/clients/compass-canada/features/order-fulfillment-status-per-line.md +1 -1
  27. package/knowledge/clients/compass-usa/INDEX.md +1 -0
  28. package/knowledge/clients/compass-usa/features/contact-email-resolution.md +149 -0
  29. package/knowledge/clients/compass-usa/features/order-fulfillment-status-per-line.md +1 -1
  30. package/package.json +1 -1
@@ -2,4 +2,4 @@
2
2
 
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
- | [NetSuite → ClickUp Opportunity Sync](features/netsuite-clickup-opportunity-sync.md) | NetSuite Opportunities are pushed into the ClickUp **Opportunities** list (list id `901111987449`, in the Opportunity/RFP Hub space `90113928591`) as tasks name | webhook/_/webhook/netsuite.php, webhook/api.php, api2/Webhook/Netsuite.php, worker2/Worker/Netsuite.php, api2/Controller/Index.php |
5
+ | [NetSuite → ClickUp Opportunity Sync](features/netsuite-clickup-opportunity-sync.md) | > **⚠ CORRECTED 2026-09-04 — parts of this doc were factually wrong and actively misleading.** > Two claims below have been retracted in place: that > `worker2/ | webhook/_/webhook/netsuite.php, webhook/api.php, api2/Webhook/Netsuite.php, worker2/Worker/Netsuite.php, api2/Controller/Index.php |
@@ -6,17 +6,33 @@ project: Webhook
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-17
10
- owners: ["dfranks"]
9
+ updated: 2026-09-04
10
+ owners: ["dfranks", "ajean"]
11
11
  files:
12
12
  - webhook/_/webhook/netsuite.php
13
13
  - webhook/api.php
14
14
  - api2/Webhook/Netsuite.php
15
15
  - worker2/Worker/Netsuite.php
16
16
  - api2/Controller/Index.php
17
- related: []
17
+ related:
18
+ - ../../../2.0/apps/worker2/features/netsuite-opportunity-sync.md
18
19
  ---
19
20
 
21
+ > **⚠ CORRECTED 2026-09-04 — parts of this doc were factually wrong and actively misleading.**
22
+ > Two claims below have been retracted in place: that
23
+ > `worker2/Worker/Netsuite/Opportunity.php` and `maybeCreateClickupTask()` **do not exist**, and that
24
+ > the integration has **"no dedup of any kind."** Both files exist and are git-active, and the 2.0
25
+ > path carries an **atomic DB claim** on `Forecast.Opportunities.clickupTaskId` plus folder-wide
26
+ > dedup. Those two claims are why a later session opened with a contradiction about which app owns
27
+ > the receiver. Canonical detail:
28
+ > [NetSuite ↔ ClickUp Opportunity Sync (worker2)](../../../2.0/apps/worker2/features/netsuite-opportunity-sync.md).
29
+ >
30
+ > **Scope caveat on everything else here:** the 1.0 `webhook` repo is **not checked out** on the
31
+ > machine used for the 2026-09-04 review and could not be found anywhere on disk, so the claims in
32
+ > this doc about the *live 1.0 receiver* are **unverified as of that date**. The NetSuite-side
33
+ > content (scheduled script **2412**, UserEvent **2493**, the `OPP <id>` stub root cause and its fix)
34
+ > was **not** in question and stands.
35
+
20
36
  ## Summary
21
37
  NetSuite Opportunities are pushed into the ClickUp **Opportunities** list (list id `901111987449`,
22
38
  in the Opportunity/RFP Hub space `90113928591`) as tasks named `<oppNumber> — <customerNumber>
@@ -74,23 +90,32 @@ the assignment fires 2493 in `edit` (full-record) context → proper name (see G
74
90
  None — single internal Agilant/True integration.
75
91
 
76
92
  ## Gotchas / known issues
77
- - **Create-only, NO dedup of any kind (confirmed 2026-07-17).** `Webhook_NetSuite::post()`
78
- json-decodes the payload and calls `createTask()`, which POSTs a **new** task to ClickUp
79
- list `901111987449` **unconditionally on every delivery** (writing link `db_client`). There
80
- is no idempotency check whatsoever — so it double-creates a task on **any** repeat or
81
- concurrent delivery, not only concurrent ones.
93
+ - **Create-only with no dedup *in this 1.0 handler* — but the claim "NO dedup of any kind" was
94
+ WRONG and is retracted (2026-09-04).** As originally written (2026-07-17) this bullet asserted the
95
+ integration as a whole had "no idempotency check whatsoever." That is false at the platform level:
96
+ the **2.0** path (`worker2/Worker/Netsuite/Opportunity.php`) carries a **race-safe atomic claim** —
97
+ a guarded `UPDATE Opportunities SET clickupTaskId=… WHERE … AND clickupTaskId IS NULL` — plus
98
+ folder-wide dedup by `Opportunity #`. What remains true, scoped correctly: **`Webhook_NetSuite::post()`
99
+ in the 1.0 `webhook` repo** json-decodes the payload and calls `createTask()`, which POSTs a new
100
+ task **unconditionally on every delivery** (writing link `db_client`), so *that* handler
101
+ double-creates on any repeat or concurrent delivery. Unverified since 2026-09-04 (repo not checked
102
+ out).
82
103
  - **Live payload carries no immutable opportunity identifier (confirmed 2026-07-17).** The
83
104
  inbound body has **only**: `name`, `description`, `presalesLead`, `salesRepText`, `token`.
84
105
  It has **no NetSuite opportunity internal id and no opportunity/customer number**, so there
85
106
  is no stable key available in this handler to dedup on today. Any dedup fix must first get
86
107
  NetSuite (script 2493) to include an immutable id in the payload, or derive one another way.
87
- - **Where the swapped Opp#/Cust# mapping actually lives.** The
88
- `OPPORTUNITY_NUMBER ← customerNumber` / `CUSTOMER_NUMBER ← opportunityNumber` swap is in the
89
- **dormant 2.0 port** `worker2/Worker/Netsuite.php` (class `_Worker_NetSuite`, ~lines 116–121),
90
- which is **NOT live**. The live `webhook/_/webhook/netsuite.php` payload doesn't even carry
91
- those number fields (see above). There is **no** `worker2/Worker/Netsuite/Opportunity.php` and
92
- **no** `maybeCreateClickupTask()` method anywhere — earlier plans referencing those paths were
93
- citing files that do not exist.
108
+ - **Where the swapped Opp#/Cust# mapping lives — with the "these files don't exist" claim
109
+ RETRACTED (2026-09-04).** The `OPPORTUNITY_NUMBER ← customerNumber` /
110
+ `CUSTOMER_NUMBER ← opportunityNumber` swap is in the **dormant 2.0 port**
111
+ `worker2/Worker/Netsuite.php` (class `_Worker_NetSuite`, ~lines 116–121). That part stands.
112
+ **What was wrong:** this bullet claimed there is "**no** `worker2/Worker/Netsuite/Opportunity.php`
113
+ and **no** `maybeCreateClickupTask()` method anywhere," and that plans citing them were citing
114
+ fictional files. **Both exist and are git-active.** `_Worker_Netsuite_Opportunity` is the current
115
+ 2.0 handler — it does the Forecast upsert, holds the atomic `clickupTaskId` claim, and
116
+ `maybeCreateClickupTask()` is its ClickUp entry point. It also **fixed** the swap (2026-06-16).
117
+ Do not use this bullet to conclude the 2.0 path is vapour; read
118
+ [the worker2 doc](../../../2.0/apps/worker2/features/netsuite-opportunity-sync.md).
94
119
  - **`OPP <internalId>` degenerate tasks (root-caused & confirmed 2026-06-15).** A task named just
95
120
  `OPP 7147699` with no description/fields. The name is built by NetSuite and relayed verbatim;
96
121
  the opps are **fully populated** in NetSuite (so it's not missing data, not a TOGA bug). Cause:
@@ -148,6 +173,16 @@ Done 2026-06-15 for opps 7147699/7147698/7147472/6382879 → 4 proper tasks crea
148
173
  (task ids `868k0tr41`, `868k0tr16`, `868k0rwtp`, `868k0h44m`) were archived in ClickUp afterward.
149
174
 
150
175
  ## Change history
176
+ - 2026-09-04 — **Corrected two factually wrong claims (retracted in place; no code changed).** This
177
+ doc asserted that `worker2/Worker/Netsuite/Opportunity.php` and `maybeCreateClickupTask()` "do not
178
+ exist" and that the integration has "no dedup of any kind." Both files exist and are git-active,
179
+ and the 2.0 path carries an **atomic DB claim** on `Forecast.Opportunities.clickupTaskId` plus
180
+ folder-wide dedup; the create-only/no-dedup statement is true only of the **1.0 `webhook` handler**
181
+ and has been rescoped to it. These two claims were the reason a later session opened with a
182
+ contradiction about which app owns the receiver. Added a scope caveat: the 1.0 `webhook` repo is
183
+ **not checked out** on the reviewing machine and could not be found on disk, so this doc's claims
184
+ about the live 1.0 receiver are **unverified as of 2026-09-04**; the NetSuite-side script 2412/2493
185
+ content was never in question and stands. (ajean)
151
186
  - 2026-07-17 — Confirmed against live code: `createTask()` POSTs to list `901111987449`
152
187
  **unconditionally with no dedup** (double-creates on any repeat/concurrent delivery); the
153
188
  live payload carries only `name`/`description`/`presalesLead`/`salesRepText`/`token` — **no
@@ -6,9 +6,9 @@
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
- | [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 |
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/periodic_forecast_discrepancy_fix_opportunities.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 |
13
13
  | [NetSuite Sales Order Sales Rep Sourcing (Staples & ODP EDI orders)](features/netsuite-sales-order-sales-rep-sourcing.md) | How the **sales rep** on a NetSuite Sales Order is determined for the two 1.0 `worker` EDI order-creation integrations (Staples cXML and Compass/ODP EDI). | worker/crons/toga2/compass/workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php, worker/crons/sync/staples/sync_staples_cxml.php, test/@Mark/NetSuite/TRUE_80451_customer_salesrep_diag.php |
14
14
  | [NetSuite → TOGa Supply Per-Client Sync (thin wrappers)](features/netsuite-togasupply-per-client-sync.md) | Syncs NetSuite transactions (sales orders, purchase orders, invoices, item receipts, item fulfillments, inventory adjustments) into each TOGa Supply (2.0) clien | worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/sync_togasupply_canon.php, worker/crons/toga2/netsuite/sync_togasupply_elite.php, worker/schedules/cron.worker.sync.json, dbchanges2/_modules/netsuite/2026-04-01 - Parameters.sql, library/app/api/toga2.php, library/app/api/netsuite/rest.php, library/app/query.php, library/app/database.php, library/app/framework.php, library/app/systemmonitor/netsuiteintegration.php, dbchanges2/Client/2026-09-02b - NetsuiteSyncCursorRename.sql, test/@srija/Elite Testing/Service Requests/test_sync_togasupply_elite_section.php, test/@srija/Elite Testing/Service Requests/test_diagnose_togasupply_elite.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,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-28
10
- owners: [dfranks, jcardinal, kyalamarthi]
9
+ updated: 2026-09-04
10
+ owners: [dfranks, jcardinal, kyalamarthi, ajean]
11
11
  files:
12
12
  - test/@dave/checker.php
13
13
  - worker2/Component/Forecast/SaleImport/SaleImport.php
@@ -32,6 +32,7 @@ files:
32
32
  - test/@dave/probe_profit_gap.php
33
33
  - worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php
34
34
  - worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_open_orders.php
35
+ - worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_opportunities.php
35
36
  - worker/crons/toga2/forecast2/import_open_orders.php
36
37
  - worker/schedules/cron.worker.infrastructure.json
37
38
  related:
@@ -373,6 +374,50 @@ have broken **every insert the cron makes**. It was caught only by rendering the
373
374
  stub harness before any run. **`php -l` cannot catch malformed SQL string assembly**; when you edit a
374
375
  query *builder*, print the built query and read it.
375
376
 
377
+ ### ⚠ The opportunities discrepancy-fix cron DELETES rows that carry a ClickUp link (2026-09-04)
378
+
379
+ **This is a live data-loss path — treat it as the highest-priority item in this doc.**
380
+ `worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_opportunities.php` (209 lines,
381
+ `0 4 * * *`, **active**) and the same code path in `tools/bin/forecast/fixer.php`
382
+ (`fixOpportunities()`) both build their delete set **without ever selecting or checking
383
+ `clickupTaskId`**.
384
+
385
+ **Blast radius, measured 2026-09-04: 746 of 26,766 in-window rows carried a ClickUp task id**, and
386
+ one row was already stranded at the `PENDING` sentinel. Deleting such a row **orphans the ClickUp
387
+ task** and, because the 2.0 webhook's atomic claim keys on `Forecast.Opportunities.clickupTaskId`,
388
+ the next NetSuite event for that opportunity finds no claim and **creates a duplicate task**. The
389
+ trigger does not require anything exotic: **a single truncated `listOpportunities()` page makes every
390
+ unreturned opportunity look stale**, so an API hiccup is enough to mass-delete live rows.
391
+ Claim semantics and the duplicate-creation consequence:
392
+ [NetSuite ↔ ClickUp Opportunity Sync](../../../../2.0/apps/worker2/features/netsuite-opportunity-sync.md).
393
+
394
+ Three further defects found in the same file:
395
+
396
+ 1. **⚠ `MAINTENANCE_MODE = true` gates LOGGING ONLY — it does NOT stop writes.** Its sibling
397
+ `periodic_forecast_discrepancy_fix_open_orders.php` uses the same-named constant as a genuine
398
+ **dry run**. So the reasonable assumption — "same constant, same meaning, safe to leave on while I
399
+ watch it" — is **wrong here, and it deletes**. This is a genuine trap, not a style inconsistency:
400
+ verify per file before relying on it.
401
+ 2. **The INSERT omits seven columns:** `forecastCategoryId`, `salesStageId`,
402
+ `percentToCloseStatusId`, `projectedProfit`, `leadSource`, `clickupTaskId`, and now
403
+ `endCustomerName`. Rows the cron recreates therefore come back stripped of all seven — the same
404
+ delete-and-reinsert failure shape as the `locationId` case above.
405
+ 3. **Empty `sql_mode` in prod turns omission #2 into silent corruption.** `projectedProfit` is
406
+ NOT NULL with **no default**, so instead of rejecting the INSERT, MySQL silently stores `0.00`.
407
+ **This is the root cause of the perpetual checker↔fixer churn** this doc has tracked for months:
408
+ the checker flags a profit delta, the fixer "repairs" the row, the cron zeroes it again the next
409
+ night.
410
+
411
+ **And the reconciler cannot compute `projectedProfit` at all.** It fetches **no line items** — zero
412
+ references to `itemList` or `costEstimate` anywhere in the file — while opportunity profit is
413
+ Σ(amount − costEstimate) over qualifying lines (the formula recorded above). So it cannot fix the
414
+ column it breaks, and there is no version of "just add `projectedProfit` to the INSERT" that works
415
+ without adding line-item fetching first.
416
+
417
+ **Agreed direction (2026-09-04): the reconciler should STOP INSERTing and report instead.** A
418
+ component that cannot compute a NOT-NULL column should not be authoring rows. Detail in
419
+ [the bidirectional opportunity-sync design](../../../../2.0/apps/worker2/features/netsuite-clickup-opportunity-bidirectional-design.md).
420
+
376
421
  ## Data model
377
422
 
378
423
  `Forecast.Sales`, `Forecast.OpenOrderItems` on the **core2** cluster
@@ -654,6 +699,21 @@ None — Forecast2 is a single shared dataset.
654
699
  sequence the rotation against whatever else reads that replica.
655
700
 
656
701
  ## Change history
702
+ - 2026-09-04 — **Investigation only; no code changed (a prototype fix was written and deliberately
703
+ reverted).** Recorded a **live data-loss path**: the nightly
704
+ `periodic_forecast_discrepancy_fix_opportunities.php` (`0 4 * * *`, active) and
705
+ `tools/bin/forecast/fixer.php::fixOpportunities()` build their delete set **without checking
706
+ `clickupTaskId`** — **746 of 26,766 in-window rows carried a task id** (one already stranded at
707
+ `PENDING`), so a delete **orphans the ClickUp task and causes duplicate task creation**, and a
708
+ single truncated `listOpportunities()` page is enough to make every unreturned opportunity look
709
+ stale. Three more defects in the same file: **`MAINTENANCE_MODE = true` gates logging only, NOT
710
+ writes** (unlike its `..._open_orders.php` sibling, where it *is* a dry run — a genuine trap); the
711
+ INSERT **omits** `forecastCategoryId`, `salesStageId`, `percentToCloseStatusId`, `projectedProfit`,
712
+ `leadSource`, `clickupTaskId` and `endCustomerName`; and because prod `sql_mode` is **empty**,
713
+ `projectedProfit` (NOT NULL, no default) silently lands as **`0.00`** — the **root cause of the
714
+ long-standing checker↔fixer churn**. Also established that the reconciler **cannot compute
715
+ `projectedProfit` at all** (it fetches no line items — zero `itemList`/`costEstimate` references),
716
+ so the agreed direction is that it **stops INSERTing and reports** instead. (ajean)
657
717
 
658
718
  - 2026-08-28 — **⚠ Security finding (not fixed):** the `tools` repo copies of these scripts —
659
719
  `tools/bin/forecast/fixer.php:1424` and `tools/bin/forecast/checker.php:678` — hardcode **live
@@ -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
@@ -7,7 +7,7 @@
7
7
  | [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, dbchanges2/Core/2026-08-27a - TransferOrderUuidSearchableIdentifier.sql, _underscore/Model/Core/Page.php, _underscore/Model/Core/Surface.php, _underscore/Model/Client/TrackingNumber.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql, dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql, dbchanges2/Client/2026-08-29 - ItemReceiptTransferOrderIdFieldPermission.sql, dbchanges2/Client/2026-08-29b - ItemReceiptItemTransferOrderItemIdFieldPermission.sql, dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql |
8
8
  | [Address Uniqueness Normalization (unit identifier + 5-digit ZIP comparison)](features/address-uniqueness-normalization.md) | When a business rule says *"only one X per physical address"*, comparing address rows field-for-field does **not** work: the same dwelling is spelled many diffe | _underscore/Model/Rate/Entitlement.php |
9
9
  | [Address Validation (carrier waterfall + validateAddress scripted endpoint)](features/address-validation.md) | `_Model_Client_Address::validateAddress` verifies a US address against a **carrier waterfall (USPS → FedEx → UPS)** and returns a single canonical, carrier-norm | _underscore/Model/Client/Address.php, _underscore/Component/Library/Carriers/Usps/Usps.php |
10
- | [_ApiRequest — JSON encode/decode & api-logging behavior](features/apirequest-json-content-type.md) | `_ApiRequest` is the 2.0 outbound HTTP client. | _underscore/ApiRequest.php |
10
+ | [_ApiRequest — JSON encode/decode & api-logging behavior](features/apirequest-json-content-type.md) | `_ApiRequest` is the 2.0 outbound HTTP client. | _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php, _underscore/Component/Api/Clickup/Clickup.php |
11
11
  | [Assortment Name Translation (AssortmentTranslations sidecar)](features/assortment-name-translation.md) | Serves Assortment (product-grouping) **names** in multiple languages by adding a per-language **sidecar** table `AssortmentTranslations`, reusing the platform's | _underscore/Model/Client/AssortmentTranslation.php, dbchanges2/Client/2026-06-26a - AssortmentTranslations.sql, dbchanges2/Core/2026-06-26a - AssortmentTranslationsRecord.sql, dbchanges2/Client/2026-06-26b - AssortmentTranslationsAcl.sql |
12
12
  | [Asynchronous Query Execution (writes-only, via Worker)](features/async-query-execution.md) | `_Query` can run a **write** query asynchronously so a long/slow write does not hold a request-scoped DB connection open long enough to hit **"MySQL server has | _underscore/Query.php, worker2/Worker/Infrastructure/Database.php, worker2/Worker/Team/Transcripts.php |
13
13
  | [FIELD_AUTOINCREMENT record numbering (SA / TA prefixes, NULL-only trigger, no padding)](features/autoincrement-record-numbering.md) | A human-facing record number (`SA100001`, `TA100000`) is generated by the ORM, not by MySQL. | _underscore/Model.php, _underscore/Model/Client/SalesOrder.php, _underscore/Model/Client/TransferOrder.php |
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-08-26
10
- owners: ["jcardinal", "rgirish", "mhammontree"]
9
+ updated: 2026-09-04
10
+ owners: ["jcardinal", "rgirish", "mhammontree", "ajean"]
11
11
  files:
12
12
  - _underscore/_underscore.php
13
13
  - _underscore/Loader.php
@@ -31,7 +31,10 @@ Minimum PHP 8.1 (enforced in `_underscore.php`). The current architecture is
31
31
  **Critical rules:** framework classes use a leading-underscore prefix (`_Model_*`, `_Controller_*`,
32
32
  `_Worker_*`). Always use parameterized queries / the `_Db` layer — never interpolate input into SQL.
33
33
  Beware the **lazy-transaction gotcha**: writes issued outside an explicitly committed transaction
34
- can be silently dropped — confirm commit semantics before relying on a write.
34
+ can be silently dropped — confirm commit semantics before relying on a write. Two corollaries:
35
+ **never hold a DB transaction open across an external HTTP call**, and **an empty log table is not
36
+ proof a code path never ran** — uncommitted rows vanish, so absence of log rows means "no committed
37
+ write", not "no execution".
35
38
  The Surface presentation layer replaces `Page::meta()` but `meta()` must NOT be deleted until
36
39
  every page is cut over (per-surface, with a parity diff); config describes, PHP decides —
37
40
  business logic never moves into Surface config.
@@ -369,6 +372,18 @@ multi-file UI components (`.php`/`.html`/`.css`/`.js`) invoked as `<_ComponentNa
369
372
  pin is `Addresses.isValidated` and `Entitlements.serviceAddressId` both-or-neither. Relevant code:
370
373
  `_underscore/Model/Rate/Entitlement.php`, `api2/Component/Api/V2/V2.php::getFullModelData`.)
371
374
  - **`_Database::register()` auto-starts a lazy transaction (since Apr 2 2026, commit `fa7835ed`).** Any code that calls `register()` and then writes to that DB must call `_Database::transactionCommit()` before the request ends — otherwise MySQL silently rolls back all writes when the connection closes. Lazy transactions only materialise on the first write, so read-only callers are unaffected. See `_underscore/Database.php:48`. First discovered when Rate SAML user provisioning silently discarded all new user INSERTs (Jun 2026).
375
+ - **Never hold a DB transaction open across an external HTTP round-trip.** Beyond pinning a
376
+ connection for the duration of a third-party call, it means the log/telemetry rows you are
377
+ writing *about* that call are still uncommitted while it runs. Live instance found 2026-09-04:
378
+ `_Worker_Clickup_Opportunity::logged()` opens `DB_LOGS`, never commits, and holds the
379
+ transaction across every ClickUp/NetSuite call.
380
+ - **An empty log table is NOT proof that a code path never ran.** This is the diagnostic trap the
381
+ gotcha creates, and it has already produced one wrong conclusion in this knowledge base: a
382
+ zero-row `Logs.Api` census was read as "this integration has been dead since it shipped," when
383
+ the writes were simply never committed. Before concluding a path is dead from absent rows,
384
+ confirm the writer commits. Correct pattern:
385
+ `worker2/Worker/Netsuite/SalesOrder.php:441`. Case detail:
386
+ [netsuite-opportunity-sync](../worker2/features/netsuite-opportunity-sync.md).
372
387
  - **`_Model::load()` with a collapsed WHERE clause silently full-table-scans → OOM (guarded 2026-08-26, `Model.php`).** `_Model::search()` builds `SELECT <pk> FROM <table> [WHERE …] ORDER BY <pk>` and emits the WHERE **only if at least one search term survives**. `buildSqlFieldValue()` **drops** a term (returns false) when a set field's value is `null` and its type is a plain `FIELD_CHAR` — i.e. not one of the special-cased types (`AUTOINCREMENT`, `DATETIME_CREATED`, `DATETIME_UPDATED`, `SQL`, `STORAGE`, `CHAR_UUID`). If the only set search field collapses this way, the WHERE vanishes and `search()` selects the **whole table**, constructing one model per row → memory exhaustion on a large table. Because `load()` calls `search()` first and only then checks `count() == 1`, it **OOMs building the result array before it ever counts** — the empty-WHERE case never reaches the "not exactly one" branch. **`load()` means "find exactly one by criteria"; an empty WHERE is always a bug, never a full-table scan.** **Fix (commit `0b944a51`, `_production`):** `load()` now guards at the top — if no set search field yields a WHERE term (via `buildSqlFieldValue`), it `error_log`s and returns `false` (or throws when `throwExceptionIfNotFound`) instead of scanning. `search()` is unchanged, so intentional "list all rows" callers of `search()` are unaffected. **Triage:** a PHP OOM stack trace of `Model.php search() → __construct` that is *shallow* (a single search→construct, not deeply repeated) is a runaway result set from a collapsed WHERE, **not** recursion — confirm by checking whether the single set search field is a `FIELD_CHAR` whose value is null. (Root cause of a recurring prod OOM: `Logs.Issue` ref `2L` (#38), first seen 2026-08-03, 136 occurrences, trace `Model.php search()→__construct`.)
373
388
  - **PHP "Unclosed '{'" parse errors report a MISLEADING line number.** When a `.php` file loaded by the autoloader (`Loader.php`) has a dropped/unbalanced brace, PHP reports `Unclosed '{' on line N` where N is the **outermost `class X {` line** and fails at EOF — NOT at the true location of the missing `}`. Worse, because the file loads lazily via the SPL autoloader, the runtime trace points at the **caller** that triggered the autoload (e.g. a `new _Email()` call site), not the broken file. **Triage rule:** for an "Unclosed '{'" error, the real culprit is a missing `}` somewhere between the reported line and EOF of the file that failed to load — run `php -l <file>` (it reports the EOF line) and scan the whole file. **Merge-conflict resolutions are a common source of a single dropped brace** — review the entire merge, not just the one file the error appears to name. (First hit: production 500 EO-1, Jul 2026 — a `}` dropped from `_Email::send()` during merge `685e4a14` surfaced as a trace pointing at the `new _Email()` caller.)
374
389