toga-ai 1.0.620 → 1.0.622

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.
@@ -9,6 +9,7 @@
9
9
  | [Cron Execution Monitoring (App_Framework check-in/out → CronJobExecutions)](features/cron-execution-monitoring.md) | `App_Framework::cronInitialization()` / `App_Framework::cronFinished()` (in `library/app/framework.php`) give every 1.0 (`App_`) cron job a check-in/check-out l | library/app/framework.php |
10
10
  | [Diagnostic Dialog — View Recommended Services Routing](features/diagnostic-dialog-view-recommended-services.md) | Two "View Recommended Services" buttons exist in the TOGa Refresh 2026 SR view: 1. | library/app/model/toga/diagnostic.php, library/app/model/servicerequest.php |
11
11
  | [Elite Freshservice Sync (library)](features/elite-freshservice-sync.md) | `App_Api_Toga2` in `library/app/api/toga2.php` orchestrates bidirectional sync between TOGA 2 and TOGaDesk. | library/app/api/toga2.php |
12
+ | [App_Email Queued Sending & Attachments (Common.EmailsQueued)](features/email-queue-attachments.md) | `App_Email::send()` can either send **inline** (PHPMailer talks to SES right there) or **queue** the message: `base64(serialize($this))` is inserted into `Commo | library/app/email.php, library/phpmailer/class.phpmailer.php, worker/crons/notifications/infrastructure/send_emails.php, worker/crons/notifications/covid/send_covid_pending_vaccination_approval.php, togadesk/desk/includes/functions.php |
12
13
  | [Branded HTML Email Templates (App_Email_Template)](features/email-templates.md) | `App_Email_Template` (`app/email/template.php`) is the base class for branded HTML emails in the 1.0 (`App_`) framework. | library/app/email/template.php, library/app/email/agilant.php |
13
14
  | [Error Capture in 1.0 (App_Error_Capture → shared 2.0 Logs DB)](features/error-capture-1-0.md) | The 1.0 side of the platform error-reporting pipeline (TRUE-78188). | library/app/error/capture.php, library/app/error.php, library/app/exception/business.php, library/app/api/toga2.php, library/app/cloud.php, worker/config.worker.ini, worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php |
14
15
  | [HTTP 500 Error Monitor (App_SystemMonitor_500Error) — and why its \"Error Type\" is not a diagnosis](features/http-500-error-monitor.md) | `App_SystemMonitor_500Error` (`library/app/systemmonitor/500error.php`, title **"HTTP 500 Error Alert"**) is the 1.0 system monitor that watches **`Logs.Api` fo | library/app/systemmonitor/500error.php, worker/crons/infrastructure/system_monitors.php, api2/Controller/Index.php, _underscore/Error.php |
@@ -0,0 +1,112 @@
1
+ ---
2
+ title: App_Email Queued Sending & Attachments (Common.EmailsQueued)
3
+ framework: "1.0"
4
+ repo: library
5
+ project: Library
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-20
10
+ owners: ["mhammontree"]
11
+ files:
12
+ - library/app/email.php
13
+ - library/phpmailer/class.phpmailer.php
14
+ - worker/crons/notifications/infrastructure/send_emails.php
15
+ - worker/crons/notifications/covid/send_covid_pending_vaccination_approval.php
16
+ - togadesk/desk/includes/functions.php
17
+ related:
18
+ - ./email-templates.md
19
+ - ../architecture.md
20
+ - ../../togadesk/features/notifications.md
21
+ ---
22
+
23
+ ## Summary
24
+
25
+ `App_Email::send()` can either send **inline** (PHPMailer talks to SES right there) or **queue**
26
+ the message: `base64(serialize($this))` is inserted into `Common.EmailsQueued` and drained later
27
+ by a **different app on a different host** — `worker/crons/notifications/infrastructure/send_emails.php`,
28
+ scheduled `* * * * *`.
29
+
30
+ Two facts dominate everything else here:
31
+
32
+ 1. **Attachments used to be queued as filesystem *paths*.** The drain host has no such path, and
33
+ PHPMailer 5 silently swallows an unreadable attachment, so the mail arrived with the body
34
+ intact and the attachment gone — **no error, no log**. Fixed 2026-08-20 (TRUE-80952) by
35
+ embedding the file **bytes** at queue time.
36
+ 2. **Queued email does not queue outside production.** `setQueueForSending()` is gated on
37
+ `!App_Registry::inTestMode()`, and every non-prod config has `internal.test_mode = 1`. So on
38
+ alpha/beta/local the message is sent **inline** off local disk and attachments always work —
39
+ any test there is a **false pass**.
40
+
41
+ ## How it works
42
+
43
+ - `App_Email::setQueueForSending($bool)` (`library/app/email.php` ~L161):
44
+ `$this->queueForSending = (($bool && !App_Registry::inTestMode()) ? … : false);`
45
+ - `send()` with queueing on serializes `$this` and inserts into `Common.EmailsQueued`
46
+ (`serializedEmailObject`, LONGTEXT).
47
+ - Before serializing, `App_Email::embedAttachmentsForQueue()` (private, added TRUE-80952) reads
48
+ each attachment into **base64 bytes**. On drain, embedded entries go out via PHPMailer's
49
+ `AddStringAttachment()`; legacy bare-string path entries still use `AddAttachment()`.
50
+ - **Size budget:** `MAX_QUEUED_ATTACHMENT_BYTES = 7340032` (7 MB raw ≈ 9.8 MB base64, under the
51
+ SES 10 MB message cap). Over budget, embedding returns false and `send()` **falls back to an
52
+ inline send** instead of queueing — except when outbound email is disabled, where there is no
53
+ inline fallback and the case is logged.
54
+ - The file read is wrapped in a **local `set_error_handler` → `ErrorException` with a `finally`
55
+ restore**. This is mandatory, not stylistic: in 1.0 any PHP warning routes through
56
+ `App_Error::handleError` → `handleException` → `exit()`, so an unguarded `file_get_contents`
57
+ warning would kill the `send_emails` cron **mid-loop** and strand every queued email behind it.
58
+ `is_file()`/`is_readable()` are not sufficient — the file can vanish between check and read (TOCTOU).
59
+
60
+ ## Blast radius (verified 2026-08-20)
61
+
62
+ Only **two** 1.0 call sites both queue *and* attach: TOGa Desk's `sendEmail()`
63
+ (`togadesk/desk/includes/functions.php`) and
64
+ `worker/crons/notifications/covid/send_covid_pending_vaccination_approval.php` (~L44 + L82, bare
65
+ string path — it had the identical bug and is fixed by the same change). worker's other 16
66
+ `setQueueForSending` sites pass `false`; togaview's two queueing sites (`mvc/signup/post.php`,
67
+ `mvc/reset_password_success/post.php`) attach nothing; `tools` and `walmarttechservices` never
68
+ attach. Inline sending and bare-string path attachments are unchanged.
69
+
70
+ ## Deploy ordering (asymmetric — and so is rollback)
71
+
72
+ The queue payload format changed, and the queueing app and the draining app deploy separately.
73
+
74
+ - worker **new** + togadesk **old** → togadesk queues paths, new worker still handles paths. Safe.
75
+ - worker **old** + togadesk **new** → togadesk queues `['data' => …]`, the old worker looks for
76
+ `['path']`, finds none, and **drops the attachment** — same symptom, new cause.
77
+
78
+ Therefore: **deploy `worker` before `togadesk`.** Roll back in the inverse order — roll back
79
+ togadesk first, let the queue drain empty, *then* worker.
80
+
81
+ ## Gotchas / known issues
82
+
83
+ - **You cannot reproduce or validate a queued-email bug outside production.** `test_mode = 1` on
84
+ alpha, beta and every dev config collapses `setQueueForSending(true)` to `false`. This is the
85
+ most likely reason TRUE-75813 and TRUE-79924 were "verified" and closed while prod stayed
86
+ broken — both corrected the upload path *inside togadesk*, which was already working; the file
87
+ was lost one hop later, at the queue. Locally the queueing app and the drain also share one
88
+ filesystem, so even with queueing forced on the path resolves — you must **delete the source
89
+ file between queue and drain** to simulate the drain host.
90
+ - **PHPMailer 5 hides a missing attachment.** `AddAttachment()`
91
+ (`library/phpmailer/class.phpmailer.php` ~L1358) treats an unreadable path as `STOP_CONTINUE`
92
+ with `$exceptions` off, and sends the message anyway. Absence of an error proves nothing.
93
+ - **The drain cron's exclusion filter is dead code.** `send_emails.php` ~L34 carries
94
+ `AND EmailsQueued.serializedEmailObject NOT LIKE '%App_Email%'`. The column holds base64, in
95
+ which the literal `App_Email` never appears (verified with a PHP round-trip) — the filter
96
+ matches and excludes nothing. **Not changed**: "fixing" it would stop TOGa Desk mail entirely.
97
+ Needs a decision from whoever wrote it before anyone touches it.
98
+ - **OPEN RISK — `max_allowed_packet` on the prod Common cluster is unverified.** Rows can now
99
+ carry ~9.8 MB of base64. The column is LONGTEXT so it is fine, but MySQL 5.7 defaults
100
+ `max_allowed_packet` to 4 MB (8.0: 64 MB). If prod is below the budget the INSERT fails as an
101
+ `App_Query` exception on a live ticket reply — worse than a missing attachment. Check it, and
102
+ lower `MAX_QUEUED_ATTACHMENT_BYTES` beneath it if needed (the oversized path already degrades
103
+ to an inline send, which is the correct behavior).
104
+ - Regression coverage: `test/@Mark/TRUE-80952/test_queued_email_attachments.php` — 26 assertions,
105
+ no DB, no SMTP, confirmed failing against pre-fix HEAD.
106
+
107
+ ## Change history
108
+ - 2026-08-20 — TRUE-80952: queued attachments now embed base64 bytes (`embedAttachmentsForQueue()`
109
+ + `AddStringAttachment`) instead of paths that do not exist on the drain host; added
110
+ `MAX_QUEUED_ATTACHMENT_BYTES` with inline-send fallback and a scoped warning guard around the
111
+ file read; recorded the `test_mode` no-queue trap, the dead drain filter, and the
112
+ worker-before-togadesk deploy order (mhammontree)
@@ -10,7 +10,7 @@
10
10
  | [Developer Generators (password, UUID)](features/dev-generators.md) | Two tiny **1.0 `App_` framework** convenience scripts for everyday developer needs. | test/team/generate_password.php, test/team/uuid.php |
11
11
  | [Forecast vs NetSuite Discrepancy Analysis](features/forecast-netsuite-discrepancy-analysis.md) | `team/forecast-netsuite/discrepancy_analysis.php` detects discrepancies between our **Forecast database** and **NetSuite** (the source of truth for all sales da | test/team/forecast-netsuite/discrepancy_analysis.php |
12
12
  | [@goagilant.com → @togatech.com Email-Domain Migration (1.0 + 2.0)](features/goagilant-to-togatech-email-migration.md) | Reference + technique for migrating the company email domain `@goagilant.com` → `@togatech.com` across **both** platforms. | migrate_goagilant_to_togatech_2026-06-26.sql, migrate_goagilant_to_togatech_LEGACY_2026-06-26.sql |
13
- | [Static (no-DB) Regression Harness for 1.0 Logic + Source Drift Guard](features/static-no-db-regression-harness.md) | 1.0 has **no PHPUnit**, and most of its business logic sits inside methods that also write SQL, so "just call it" means standing up a client database. | test/@Mark/AIG/test_multi_email.php, library/app/database.php, library/app/api/toga2.php |
13
+ | [Static (no-DB) Regression Harness for 1.0 Logic + Source Drift Guard](features/static-no-db-regression-harness.md) | 1.0 has **no PHPUnit**, and most of its business logic sits inside methods that also write SQL, so "just call it" means standing up a client database. | test/@Mark/AIG/test_multi_email.php, test/@Mark/TRUE-80952/test_queued_email_attachments.php, library/app/database.php, library/app/api/toga2.php |
14
14
  | [TableView Builder (2.0 TableViews SQL generator)](features/tableview-builder.md) | `team/tableViewBuilder/` generates SQL `INSERT` statements for the **2.0 `TableViews`**, `TableViewFields`, and `TableViewJoins` tables from a plain SQL `SELECT | test/team/tableViewBuilder/TableViewGenerator.php, test/team/tableViewBuilder/index.php, test/team/tableViewBuilder/Instructions.md |
15
15
  | [Talos Knowledge Base Pipeline (Uploader + Processor)](features/talos-kb-pipeline.md) | `team/talos/` holds the two-script web tooling that feeds the **TOGa Talos** (TOGa IQ) AI knowledge bases. | test/team/talos/kb_uploader.php, test/team/talos/kb_processor.php, test/team/talos/kb_processor.ini |
16
16
  | [TOGa 2.0 Client Onboarding SQL Generator](features/toga2-client-onboarding-sql.md) | > **Superseded by the browser wizard.** The generation logic here was extracted into the reusable > `OnboardingSqlGenerator` class and wrapped in a local browse | test/team/generate_toga2_onboarding_sql.php |
@@ -6,10 +6,11 @@ project: Test
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-03
9
+ updated: 2026-08-20
10
10
  owners: ["mhammontree"]
11
11
  files:
12
12
  - test/@Mark/AIG/test_multi_email.php
13
+ - test/@Mark/TRUE-80952/test_queued_email_attachments.php
13
14
  - library/app/database.php
14
15
  - library/app/api/toga2.php
15
16
  related:
@@ -75,6 +76,17 @@ method**, which is a large share of `library`.
75
76
  entirely. That was out of scope because it changes production code; prefer it when you are
76
77
  already editing the method.
77
78
 
79
+ ## Second worked example — private methods via `ReflectionMethod`
80
+
81
+ `test/@Mark/TRUE-80952/test_queued_email_attachments.php` (26 assertions, PHP 7.2, no DB, no SMTP)
82
+ extends the same pattern to a **private** method: it `require_once`s `library/app/email.php`
83
+ directly and invokes `App_Email::embedAttachmentsForQueue()` through `ReflectionMethod` with
84
+ `setAccessible(true)`. It covers the embed + serialize round-trip **with the source file deleted**
85
+ (the real production failure mode), a missing file, a directory path, an empty file, the legacy
86
+ bare-string path form, idempotent re-embed, exact-limit and aggregate-limit budget checks, and
87
+ that the scoped error handler is restored. Confirmed failing against pre-fix HEAD — a 1.0
88
+ regression test is only credible if you have run it against the broken code.
89
+
78
90
  ## Gotchas / known issues
79
91
 
80
92
  - **A mirrored test proves the mirror, not production.** The drift guard is what makes it
@@ -102,6 +114,8 @@ already editing the method.
102
114
  **`worker2` / `_underscore` enforce 8.1** — a helper shared between the two must satisfy 7.2.
103
115
 
104
116
  ## Change history
117
+ - 2026-08-20 — added the TRUE-80952 `ReflectionMethod` variant (private-method coverage, source
118
+ file deleted to simulate the queue-drain host) as a second worked example (mhammontree)
105
119
 
106
120
  - 2026-08-12 — Sharpened the 7.2 lint gotcha into a real verification procedure: the default CLI is
107
121
  PHP 8.x, so `php -l` there proves nothing; use an actual 7.2 binary, add `-n` if its `php.ini` is
@@ -6,7 +6,7 @@
6
6
  | [Email-to-Ticket Intake (crons/tickets.php)](features/email-to-ticket-intake.md) | TOGa Desk ingests support email into tickets through a cron-driven IMAP poller (`crons/tickets.php`) plus a postfix pipe variant (`crons/pipe.php`). | crons/tickets.php, crons/tickets_prod.php, crons/pipe.php, desk/includes/classes/class.ticket.php, vendor/classes/class.imap.php |
7
7
  | [Field-Service Dispatch (central / repair orders)](features/field-service-dispatch.md) | The **central** subsystem is TOGa Desk's field-service dispatch domain: repair-order lifecycle, technician scheduling, onsite vs depot service, parts, and shipm | desk/includes/controllers/actions/central/, desk/includes/classes/class.repair.php, desk/includes/classes/class.repairhistory.php, desk/_/browser/datatable/central.php, desk/template/pages/central.php, desk/template/pages/central/view.php, desk/template/modals/central/addTracking.php, library/app/model/togadesk/repairorder.php, library/app/model/togadesk/repairordertracking.php, library/app/api/carrier/ups.php |
8
8
  | [Managed Service Order Create Flow (New MSO modal → tickets/add)](features/managed-service-order-create.md) | The **Managed Service Order (MSO) create flow** is how a TOGa Desk user manually creates a managed service order: pick an End User (or a Client Location), choos | desk/includes/controllers/actions/tickets/add.php, desk/template/modals/tickets/addNew.php, desk/template/pages/managed.php, desk/template/pages/getinfo.php, desk/template/footer.php, library/app/model/togadesk/repairorder.php |
9
- | [Ticket Email Notifications (notifications table)](features/notifications.md) | Which TOGa Desk emails fire for a given client is driven **entirely by data**, not code: the `TOGaDeskSupport.notifications` table holds one row per `(clientid, | desk/includes/classes/class.notification.php, crons/tickets.php, crons/tickets_prod.php |
9
+ | [Ticket Email Notifications (notifications table)](features/notifications.md) | Which TOGa Desk emails fire for a given client is driven **entirely by data**, not code: the `TOGaDeskSupport.notifications` table holds one row per `(clientid, | desk/includes/classes/class.notification.php, desk/includes/functions.php, desk/includes/classes/class.ticket.php, crons/tickets.php, crons/tickets_prod.php |
10
10
  | [Per-Client Hostname Restriction (getRestrictedClient)](features/per-client-host-restriction.md) | TOGa Desk supports **per-client branded URLs** (e.g. | desk/includes/functions.php, desk/template/header.php, _/browser/datatable/tickets, desk/includes/classes/class.ticket.php, ebs/setup_phpini.php, ebs/http_to_https.php, desk/template/modals/kb/viewDocument.php, desk/template/modals/files/aws-view.php, desk/template/modals/files/contract-view.php, template/pages/documents/view.php, template/pages/tasks/view.php |
11
11
  | [REST API (RPC-over-POST) & API-Key Auth](features/rest-api.md) | TOGa Desk exposes a programmatic API at `desk/api/`. | desk/api/index.php, desk/api/resources/tickets.php, desk/api/resources/assets.php, desk/api/resources/authenticate.php, desk/includes/classes/class.apikey.php, desk/includes/functions.php |
12
12
  | [SMB Contract Editing & the clientMspId Corruption Trap](features/smb-contract-editing.md) | The SMB contracts page (`/desk/?route=toga/smbcontracts&togaClientId=<id>`) edits `TOGA_*.SMBContracts` rows via a modal. | desk/template/modals/toga/smbcontracts/smbContract.php, desk/includes/controllers/modals/toga/smbcontracts/smbContract.php, desk/includes/controllers/actions/toga/smbcontracts/smbContract.php, desk/includes/controllers/actions/toga/smbcontracts/edit.php |
@@ -6,13 +6,16 @@ project: TOGa Desk
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-24
10
- owners: ["ajean"]
9
+ updated: 2026-08-20
10
+ owners: ["ajean", "mhammontree"]
11
11
  files:
12
12
  - desk/includes/classes/class.notification.php
13
+ - desk/includes/functions.php
14
+ - desk/includes/classes/class.ticket.php
13
15
  - crons/tickets.php
14
16
  - crons/tickets_prod.php
15
17
  related:
18
+ - 1.0/apps/library/features/email-queue-attachments.md
16
19
  - 1.0/apps/togadesk/features/ticket-lifecycle.md
17
20
  - 1.0/apps/togadesk/features/email-to-ticket-intake.md
18
21
  - 1.0/apps/togadesk/architecture.md
@@ -55,6 +58,20 @@ nothing if the `STAFF_*` row is absent.
55
58
  - `TOGaDeskSupport.people_departments` — peopleid ↔ departmentid mapping.
56
59
 
57
60
  ## Gotchas / known issues
61
+ - **Outbound attachments were dropped at the email queue, not in TOGa Desk.** `sendEmail()`
62
+ queues via `App_Email` into `Common.EmailsQueued`; until TRUE-80952 attachments were serialized
63
+ as local **paths** that do not exist on the worker host that drains the queue, and PHPMailer 5
64
+ silently sent the mail without them. Two earlier tickets (TRUE-75813, TRUE-79924) "fixed" the
65
+ upload path inside TOGa Desk — which was never the problem — and passed testing because
66
+ **alpha/beta/local run `test_mode = 1`, which disables queueing entirely and sends inline**.
67
+ Never validate an attachment/email change for TOGa Desk on a non-prod environment. See
68
+ [App_Email queued sending & attachments](../../library/features/email-queue-attachments.md).
69
+ - **The AIG branch of `sendEmail()` never attaches anything (open bug, separate from TRUE-80952).**
70
+ `desk/includes/functions.php` ~L961-975 and the AIG arm of `addReply()` in
71
+ `class.ticket.php` build `App_Email_AigTemplates_SendAigSupportTicketReplyNotification` and pass
72
+ `$attachments` only to `logEmail()` — **`addAttachment()` is never called**. Staples Protection
73
+ ticket replies therefore lose attachments for a different reason, and the TRUE-80952 fix does
74
+ not help them. Needs its own ticket.
58
75
  - **Silent disable by omission.** A missing `(clientid, type)` row produces no email and no log
59
76
  line — diagnose "missing notification" reports by querying `notifications` for that client's
60
77
  rows first, before suspecting recipient flags or code.
@@ -70,6 +87,9 @@ nothing if the `STAFF_*` row is absent.
70
87
  notification still depends on a `STAFF_NEW_TICKET` row existing for the ticket's client.
71
88
 
72
89
  ## Change history
90
+ - 2026-08-20 — TRUE-80952: recorded that outbound attachments were lost at the `App_Email` queue
91
+ (not in TOGa Desk), that non-prod `test_mode` disables queueing and yields false passes, and the
92
+ separate AIG-arm bug where `$attachments` never reaches `addAttachment()` (mhammontree)
73
93
  - 2026-06-24 — documented the `notifications` `(clientid, type)` data model and staff-recipient
74
94
  resolution; recorded the clientid-178 missing-`STAFF_*`-rows incident (ajean)
75
95
 
@@ -5,11 +5,12 @@ project: Library
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-08-14
8
+ updated: 2026-08-20
9
9
  owners: [jcardinal, rgirish, mhammontree, ajean]
10
10
  files: []
11
11
  related:
12
12
  - ../apps/library/architecture.md
13
+ - ../apps/library/features/email-queue-attachments.md
13
14
  - ../apps/library/features/error-capture-1-0.md
14
15
  - ../../2.0/standards/backend-php.md
15
16
  ---
@@ -306,6 +307,14 @@ Two sanctioned patterns, in order of preference:
306
307
 
307
308
  Never leave either one in effect beyond the block that needs it.
308
309
 
310
+ **Worked example — reading a file you are about to serialize (TRUE-80952).**
311
+ `App_Email::embedAttachmentsForQueue()` wraps its `file_get_contents` in a local
312
+ `set_error_handler` → `ErrorException` with a `finally` restore. Guarding with
313
+ `is_file()`/`is_readable()` alone is **not** sufficient: the file can vanish between the
314
+ check and the read (TOCTOU), and the resulting warning would `exit()` the
315
+ `send_emails` cron **mid-loop**, stranding every queued email behind it. Any file read
316
+ inside a loop that processes a queue takes pattern 1.
317
+
309
318
  ### Start the session before App_Error's fatal handler — and set the handler in code, not INI
310
319
 
311
320
  On EB PHP 8.5 / AL2023, the `.so` extension and cookie flags aside, two rules make Redis
@@ -501,6 +510,25 @@ Additional verified detail:
501
510
  > (~L112-123), `clients/prudential/profile.md` (~L75). Prefer the local-handler pattern
502
511
  > from the canonical section above for anything new.
503
512
 
513
+ ### Test mode silently disables queued email — non-prod cannot validate a queue bug
514
+
515
+ `App_Email::setQueueForSending()` (`library/app/email.php` ~L161) is gated on
516
+ `!App_Registry::inTestMode()`:
517
+
518
+ $this->queueForSending = (($bool && !App_Registry::inTestMode()) ? … : false);
519
+
520
+ `inTestMode()` is `config['internal']['test_mode'] > 0`, and **every non-prod config is 1**
521
+ (verified for togadesk: prod 0; alpha 1; beta 1; all `config.dev-*.ini` 1). So outside
522
+ production `setQueueForSending(true)` collapses to `false` and the email is sent **inline**,
523
+ off the local filesystem, by the same process that built it. Anything that only breaks on the
524
+ queue path — a serialization gap, a resource the drain host cannot reach — **passes on
525
+ alpha/beta/local**. TRUE-75813 and TRUE-79924 were both verified and closed this way while
526
+ production stayed broken.
527
+
528
+ Rule: a change to queued email is not verified until it is verified in production, or against
529
+ a harness that exercises the serialize → drain boundary with the queueing host's local state
530
+ removed. Treat a non-prod pass on queue code as no evidence at all.
531
+
504
532
  ## Security Best Practices
505
533
 
506
534
  ### Input Validation and Sanitization
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-18
9
+ updated: 2026-08-20
10
10
  owners: ["jcardinal", "mhammontree", "dfranks", "apeterson", "bala", "tcox"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -29,6 +29,7 @@ files:
29
29
  - dbchanges2/Client_Elite/2026-08-17 - FulfillmentTableViews.sql
30
30
  related:
31
31
  - recursive-item-fulfillments.md
32
+ - ../../api2/features/cxml-shipnotice-gateway.md
32
33
  ---
33
34
 
34
35
  ## Summary
@@ -120,6 +121,20 @@ returnTrackingNumber: {...} }]`, and an IFIU's as `itemFulfillmentItemUnitTracki
120
121
 
121
122
  ## Gotchas / known issues
122
123
 
124
+ - **⚠ The three ASN bridge `trackingNumberId` fields do NOT share a `childPolicy`, and a new client
125
+ must be provisioned per-API.** Core defaults: **1437** (unit bridge) `MATCH_CREATE`, **1441** (item
126
+ bridge) `MATCH`, **1445** (header bridge) `MATCH`. Only the unit bridge may mint a `TrackingNumbers`
127
+ row on its own. A producer that posts tracking at the item or header level (the cXML ShipNotice
128
+ translator does, since 2026-06-11) therefore needs `MATCH_CREATE` **overrides** in that tenant's
129
+ `Apis_RecordFields` for the writing API, or the write fails **EV-12 on field `trackingNumber`**.
130
+ Compass USA has all three overridden (`apiId 4`); Compass Canada had none, which rejected 100% of
131
+ its ship notices for 67 days (2026-06-11 → 2026-08-20). **Add these three override rows to the
132
+ onboarding checklist for any client whose ASNs arrive by cXML** — and ship them as a
133
+ `dbchanges2/Client_<Tenant>/` migration, since a manual patch dies on the next reseed. Do **not**
134
+ flip the Core defaults: Core ids are shared by every tenant (the same reason the IF-side 2184 flip
135
+ was rejected). Mechanics:
136
+ [nested-relationship writes](../../api2/features/nested-relationship-writes.md) and the
137
+ [cXML ShipNotice gateway](../../api2/features/cxml-shipnotice-gateway.md).
123
138
  - **Tracking is now an inherent child, not a scalar.** Any producer that sent a flat `trackingNumber`
124
139
  on a unit must nest it under the unit's `*TrackingNumbers` child array, or it is silently dropped.
125
140
  - **Pruning a unit/item means deleting its bridge rows FIRST — the bridge FKs are `RESTRICT`, not
@@ -238,6 +253,12 @@ returnTrackingNumber: {...} }]`, and an IFIU's as `itemFulfillmentItemUnitTracki
238
253
  for record 41.
239
254
 
240
255
  ## Change history
256
+ - 2026-08-20 — Recorded the **`childPolicy` asymmetry across the three ASN tracking bridges** (1437
257
+ `MATCH_CREATE`, 1441/1445 `MATCH`) and the resulting per-API `Apis_RecordFields` provisioning
258
+ requirement for any client posting item- or header-level tracking. Compass Canada had zero override
259
+ rows, so every cXML ship notice was rejected EV-12 on `trackingNumber` from the 2026-06-11
260
+ ShipControl rework until 2026-08-20. Added to the new-client onboarding checklist; Core defaults
261
+ must not be flipped. (bala)
241
262
  - 2026-08-18 - **Measured which bridge each client actually populates, and it is not what "copy
242
263
  Compass" assumes.** Added the 318-vs-319 row-count table (Compass 4,312/10,144; CompassCanada
243
264
  169/138; Elite 0/109; Prudential 0/380; Quad 0/633; NYCHH 0/unit-level) and the rule to
@@ -5,6 +5,7 @@
5
5
  | [API (api2 / TOGa API v2) Architecture](architecture.md) | `api2` is the backend powering the public **TOGa 2.0 API**. | api2/Controller/Index.php, api2/Component/Api/V2/V2.php, api2/Component/Api/Cxml/Cxml.php, api2/Component/Api/V2/Response/Response.php, api2/Config/ |
6
6
  | [API Payload Interceptors (metadata-registered prePost/postPut model hooks)](features/api-payload-interceptors.md) | A `_Model`'s `prePost()` / `postPost()` / `prePut()` / `postPut()` hooks are **not** called by the model. | api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Rate/Entitlement.php, _underscore/Model/Compass/PurchaseOrder.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/ServiceRequest.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderItem.php, dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql, dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql, dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql, dbchanges2/Client_Compass/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql, dbchanges2/Client_CompassCanada/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql |
7
7
  | [Multi-Client (Cross-Client) Data Retrieval](features/cross-client-data-retrieval.md) | A single authenticated V2 GET listing can return records across **many** clients (designed for 1000+) that the caller is entitled to, honoring **each target cli | api2/Component/Api/CrossClient/CrossClient.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Cache/Table.php, _underscore/Model/Cache/Tables/Client.php, _underscore/Model/Core/Record.php, worker2/Worker/Platform/Cache.php, worker2/Controller/Index.php, worker2/_.php, dbchanges2/Cache/2026-06-30a - MultiClientCacheTables.sql, dbchanges2/Core/2026-06-30b - CacheClusterRegistrationAndRecordTtl.sql, dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql |
8
+ | [cXML ShipNotice Gateway (ASN ingestion, carrier resolution, per-client provisioning)](features/cxml-shipnotice-gateway.md) | `_Component_Api_Cxml` accepts a supplier `ShipNoticeRequest` and translates it into a `POST /v2/advance-shipping-notices` on the V2 JSON engine. | api2/Component/Api/Cxml/Cxml.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php |
8
9
  | [Encrypted-User-UUID Auth Handoff (/auth/encrypted-user-uuid)](features/encrypted-user-uuid-auth-handoff.md) | `POST /auth/encrypted-user-uuid` is the intended **cross-client / SSO-handoff identity mechanism**: given an encrypted `{client, user}` UUID pair, it mints a fr | api2/Component/Api/CrossClient/CrossClient.php |
9
10
  | [ENVIRONMENT (not the EB environment name) decides the _underscore branch and Config file](features/environment-variable-drives-underscore-branch.md) | An api2 Elastic Beanstalk instance decides **which `_underscore` branch it clones** and **which `Config/<env>.ini` it loads** from the EB environment property * | api2/.ebextensions/git.php, api2/.ebextensions/php_include_underscore.config, api2/.ebextensions/git.sandbox-dev.json, api2/.ebextensions/git.sandbox-client.json, api2/Config/beta.ini, api2/Config/sandbox-dev.ini, api2/Component/Api/V2/V2.php |
10
11
  | [Health-check endpoint (/health liveness short-circuit)](features/health-check-endpoint.md) | `_Controller_Index::api()` short-circuits **liveness/health-probe** requests to an HTTP 200 **before** any routing, DB bootstrap, or V2 engine work runs. | api2/Controller/Index.php |
@@ -25,4 +26,4 @@
25
26
  | [V2 REST query contract (params, where grammar, encoding, ACL behavior)](features/v2-rest-query-contract.md) | What an **HTTP client** has to get right to query the Toga v2 REST API: which query params are recognized, the exact `where` grammar, how the query string is (n | api2/Component/Api/V2/V2.php |
26
27
  | [V2 reverse hasMany collections must be named in the fetch fields whitelist](features/v2-reverse-hasmany-fields-whitelist.md) | In the V2 JSON engine, a **reverse hasMany** relationship — the collection of child records that foreign-key back to a parent (e.g. | api2/Component/Api/V2/V2.php |
27
28
  | [AWS CodePipeline Deployment via CodeConnections (GitHub → Elastic Beanstalk)](workflows/codepipeline-codeconnections-deploy.md) | 2.0 apps (`api2`, `_underscore`) are deployed through **AWS CodePipeline**. | api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, api2/ebs/register_instance_to_shared_application_load_balancer.php, api2/.platform/hooks/prebuild/git.sh, api2/.ebextensions/git.php, api2/.ebextensions/git.sandbox-dev.json |
28
- | [New Environment Configuration & Provisioning (api2)](workflows/environment-configuration-and-provisioning.md) | What it takes for a 2.0 API environment (e.g. | api2/Config/<environment>.ini, api2/Controller/Index.php, dbchanges2/Core/2026-06-16a - DatabaseHosts for new QA QC stage demo environments.sql, dbchanges2/Logs/, _underscore/Route.php |
29
+ | [New Environment Configuration & Provisioning (api2)](workflows/environment-configuration-and-provisioning.md) | What it takes for a 2.0 API environment (e.g. | api2/Config/<environment>.ini, api2/composer.json, api2/composer.lock, api2/Controller/Index.php, dbchanges2/Core/2026-06-16a - DatabaseHosts for new QA QC stage demo environments.sql, dbchanges2/Logs/, _underscore/Route.php |
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-08-03
9
+ updated: 2026-08-20
10
10
  owners: [jcardinal, bala, mhammontree, dfranks]
11
11
  files:
12
12
  - api2/Controller/Index.php
@@ -14,7 +14,8 @@ files:
14
14
  - api2/Component/Api/Cxml/Cxml.php
15
15
  - api2/Component/Api/V2/Response/Response.php
16
16
  - api2/Config/
17
- related: []
17
+ related:
18
+ - features/cxml-shipnotice-gateway.md
18
19
  ---
19
20
 
20
21
  ## Summary
@@ -146,8 +147,18 @@ Core `ClientApiIdentities` → dispatch: `OrderRequest` builds a Sales Order and
146
147
  `/sales-orders`; `ShipNoticeRequest` → `/advance-shipping-notices`; `PunchOutSetupRequest`
147
148
  creates a Vision (1.0) quote, Store (1.0) cart, or TOGa Commerce session. **cXML rides on
148
149
  top of the V2 JSON engine** (it authenticates and POSTs via `_ApiRequest`), inheriting ACL,
149
- interceptors, and logging. PunchOut paths write directly to legacy **Vision/Store** (1.0)
150
- DBs (`DB_VISION_1` / `DB_STORE_1`).
150
+ interceptors, and logging. PunchOut paths write directly to legacy **Vision/Store**
151
+ (1.0) DBs (`DB_VISION_1` / `DB_STORE_1`).
152
+
153
+ **Critical rule — every internal call made from this component must run as the client resolved from
154
+ the document's `Sender > Credential`, never as a hardcoded tenant.** A hardcoded client uuid/api
155
+ credential here is a silent cross-tenant data leak: the read side uses `DB_CLIENT` and looks
156
+ correct while writes land in the wrong client DB (found 2026-08-20 in
157
+ `findOrCreateShippingCarrier()`, which wrote Compass Canada carriers into `Client_Compass`).
158
+ Second gateway-wide trap: whether a nested child (e.g. an ASN `trackingNumber`) is **created** or
159
+ only **looked up** is per-API DB provisioning — `Client_<x>.Apis_RecordFields.overrideChildPolicy` —
160
+ so a fully-provisioned client can still reject 100% of its documents. Details:
161
+ [cXML ShipNotice Gateway](features/cxml-shipnotice-gateway.md).
151
162
 
152
163
  ## Multi-database architecture
153
164
 
@@ -0,0 +1,186 @@
1
+ ---
2
+ title: cXML ShipNotice Gateway (ASN ingestion, carrier resolution, per-client provisioning)
3
+ framework: "2.0"
4
+ repo: api2
5
+ project: API
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-20
10
+ owners: ["bala"]
11
+ files:
12
+ - api2/Component/Api/Cxml/Cxml.php
13
+ - api2/Component/Api/V2/V2.php
14
+ - api2/Controller/Index.php
15
+ related:
16
+ - ../architecture.md
17
+ - nested-relationship-writes.md
18
+ - ../../_underscore/features/tracking-number-bridges.md
19
+ - ../../../clients/compass-canada/features/grand-and-toy-asn-import.md
20
+ - ../../../clients/compass-usa/features/asn-to-item-fulfillment.md
21
+ ---
22
+
23
+ ## Summary
24
+
25
+ `_Component_Api_Cxml` accepts a supplier `ShipNoticeRequest` and translates it into a
26
+ `POST /v2/advance-shipping-notices` on the V2 JSON engine. Six clients use this gateway today
27
+ (Compass USA, Compass Canada, PC Matic B2B, IM Houston and others). The translator resolves the
28
+ authenticating client from the document's `Sender > Credential`, resolves/creates the shipping
29
+ carrier, and builds the nested ASN payload including the three tracking bridges.
30
+
31
+ Two things make this path fragile, and both bit production in 2026:
32
+
33
+ 1. **Nested tracking-number creation is per-API provisioning, not code.** Whether a nested
34
+ `trackingNumber` is *created* or only *looked up* comes from
35
+ `Client_<x>.Apis_RecordFields.overrideChildPolicy` for the authenticating API. A client can be
36
+ fully provisioned for ASNs and still reject **100%** of ship notices because that table is empty.
37
+ 2. **The gateway is multi-tenant but was not written multi-tenant everywhere.** One helper
38
+ authenticated its internal API call as a hardcoded tenant, so one client's traffic wrote rows
39
+ into another client's database.
40
+
41
+ ## Key files / entry points
42
+
43
+ - `api2/Component/Api/Cxml/Cxml.php` — `execute()` authenticates the `Sender > Credential`,
44
+ dispatches `ShipNoticeRequest`, and builds the ASN payload.
45
+ `findOrCreateShippingCarrier()` resolves the carrier named in the document.
46
+ - `api2/Component/Api/V2/V2.php` — `getChildPolicy()` (~8556) decides link-vs-create for each
47
+ nested object; see [nested-relationship-writes](nested-relationship-writes.md).
48
+ - Host routing: `cxml` / `cxml1-3` / `cxml-writer` (see [architecture](../architecture.md)).
49
+
50
+ ## How it works
51
+
52
+ 1. The document arrives as `text/xml`. The `Sender > Credential` (Identity + SharedSecret) **is**
53
+ the authentication — there is no `Authorization` header on a cXML POST. So a stored document can
54
+ be replayed as-is without the caller handling any credential.
55
+ 2. The credential resolves to a client + API (`Core.ClientApiIdentities` and the tenant's `Apis`
56
+ row). Everything downstream must run **as that client**.
57
+ 3. Carrier: the header's `CarrierIdentifier` / `CarrierName` is matched against the tenant's
58
+ `ShippingCarriers` (`code = X OR name = X`), and created if absent.
59
+ 4. The ASN payload is POSTed to `/v2/advance-shipping-notices` through the V2 engine, so it inherits
60
+ ACL, interceptors and logging. The per-client ASN interceptor (Core record 55) then builds the
61
+ ItemFulfillment chain.
62
+ 5. The raw request and response are retained in full in `Logs_<Client>.Api`
63
+ (`direction = IN`, `route = '/'`, `method = POST`) — including **rejected** documents. That log is
64
+ the replay source for any backfill.
65
+
66
+ ## Per-client provisioning — the three tracking-bridge child policies
67
+
68
+ A nested `trackingNumber` inside an ASN POST is only *created* when the owning bridge field's
69
+ effective child policy is `MATCH_CREATE`. The `Core.RecordFields` defaults are **not uniform**:
70
+
71
+ | Bridge field | RecordField | Core default `childPolicy` |
72
+ |---|---|---|
73
+ | ASN **unit** bridge `trackingNumberId` | 1437 | `MATCH_CREATE` |
74
+ | ASN **item** bridge `trackingNumberId` | 1441 | `MATCH` |
75
+ | ASN **header** bridge `trackingNumberId` | 1445 | `MATCH` |
76
+
77
+ `getChildPolicy()` prefers `Client_<x>.Apis_RecordFields.overrideChildPolicy` for the
78
+ authenticating API and only falls back to the Core default. So:
79
+
80
+ - **Compass USA** cXML API (`apiId 4`) has all three overridden to `MATCH_CREATE` — its ASNs work.
81
+ - **Compass Canada** "Grand & Toy (Cxml)" API (`apiId 3`) had **zero** rows, so 1441/1445 stayed
82
+ `MATCH`, the nested tracking number was looked up instead of created, and every ship notice was
83
+ rejected **EV-12 on field `trackingNumber`**. Fixed 2026-08-20 by inserting three rows
84
+ (`apiId 3`, `recordFieldId` 1437 / 1441 / 1445, `overrideChildPolicy = MATCH_CREATE`).
85
+
86
+ > **Onboarding rule — this is the step that gets missed.** When enabling cXML ASN ingestion for a
87
+ > new client, provisioning the ASN records and ACLs is **not enough**. Insert the three
88
+ > `Apis_RecordFields` overrides for that client's cXML API, as a `dbchanges2/Client_<Tenant>/`
89
+ > migration. Verify with
90
+ > `SELECT apiId, recordFieldId, overrideChildPolicy FROM Client_<x>.Apis_RecordFields`.
91
+ >
92
+ > **Known gap today: PC Matic B2B** has the ASN records provisioned and **zero**
93
+ > `Apis_RecordFields` rows, so it will fail the same way on its first ship notice. IM Houston is
94
+ > worth the same check.
95
+
96
+ Do **not** "fix" this by flipping `Core.RecordFields` 1441/1445 to `MATCH_CREATE`: Core ids are
97
+ identical in every tenant, so that grants mint capability to every client at once. The equivalent
98
+ change on the IF-side field (2184) was already proposed and rejected under CTO review. Per-API
99
+ overrides are the sanctioned lever. Note also the sibling trap documented in
100
+ [nested-relationship-writes](nested-relationship-writes.md): a row added **only** to set a child
101
+ policy, leaving `overrideIsIdentifier` NULL, silently strips that field as an identifier.
102
+
103
+ ## Authenticate as the resolved Sender's client — never a hardcoded tenant
104
+
105
+ `findOrCreateShippingCarrier()` had two defects (fixed 2026-08-20; code is in the working tree and
106
+ **not yet committed**):
107
+
108
+ - **Cross-tenant write.** The carrier *lookup* correctly used `DB_CLIENT`, but the carrier *create*
109
+ call was authenticated with a **hardcoded Compass USA client uuid plus api credentials**, so a
110
+ carrier created for any other tenant was written into `Client_Compass`. Evidence found in prod:
111
+ 15 `PRECI`, 5 `GANDT` and 3 `XXXX` junk carrier rows in `Client_Compass` created by **Compass
112
+ Canada** traffic, plus 4 Canada tracking numbers left with a NULL carrier.
113
+ - **Wrong response key.** It read the create response from `data->shippingCarrier` (singular) while
114
+ the API returns `data->shippingCarriers` (plural), so the new uuid came back `null` even on a
115
+ successful `201`.
116
+
117
+ Fix: store the authenticated client uuid and api uuid/secret on the component when the cXML `Sender`
118
+ credential is resolved, authenticate the internal call as **that** client, read the plural key with
119
+ the singular kept as a fallback, and add `findShippingCarrierUuidByCode()` as a DB read-back if
120
+ neither key parses. Verified locally end to end. Affects all six cXML gateway clients, not just
121
+ Compass Canada.
122
+
123
+ > **Rule for any new cXML helper that calls the API: take the client/api context from the resolved
124
+ > Sender credential.** A hardcoded tenant constant in this component is a cross-tenant data leak,
125
+ > and it fails silently, because the read side uses `DB_CLIENT` and therefore looks correct.
126
+
127
+ ## Carrier resolution constraints
128
+
129
+ - `ShippingCarriers.code` holds **one** code per carrier, and the lookup is `code = X OR name = X`.
130
+ Several vendor codes therefore cannot map onto one carrier without duplicate rows — this is why
131
+ `Client_Compass` legitimately carries **7 separate FedEx rows**.
132
+ - `TrackingNumbers.shippingMethodId` is **nullable** and cXML never sends a shipping method, so the
133
+ cXML path needs **no** `ShippingMethods` row per carrier. The "every carrier needs its own Ground
134
+ method" requirement came from the retired CSV cron only — do not carry it into cXML provisioning.
135
+
136
+ ## Gotchas / known issues
137
+
138
+ - **⚠ No alerting on cXML gateway 4xx/5xx.** The gateway emails nobody on rejection. The retired
139
+ Compass Canada CSV cron emailed the team on success, failure, wrong column count and empty file;
140
+ the cXML path that replaced it is silent. This is why a **67-day, 100%-rejection outage went
141
+ unnoticed** on Compass Canada (see the
142
+ [G&T ASN import doc](../../../clients/compass-canada/features/grand-and-toy-asn-import.md)).
143
+ Highest-value remaining fix on this path: alert on any non-2xx `Logs_<Client>.Api` row for the
144
+ cXML route.
145
+ - **`Apis.maxAuthsPerHour` throttles replays.** The gateway authenticates **once per document** and
146
+ the limit is `255`/hour, so a bulk cXML replay needs roughly **20-second spacing**. The JSON
147
+ `/v2/advance-shipping-notices` route reuses one Bearer token and needs no pacing.
148
+ - **A rejected document is fully retained**, so any cXML outage is recoverable by replay from
149
+ `Logs_<Client>.Api` — dedupe on the vendor's shipment/order/tracking identifiers, never on the
150
+ log row id.
151
+
152
+ ## Testing the gateway locally (the only option while beta is down)
153
+
154
+ Beta api2 is unusable at composer bootstrap (see
155
+ [environment configuration and provisioning](../workflows/environment-configuration-and-provisioning.md)),
156
+ so gateway changes are verified on a local Laragon machine:
157
+
158
+ 1. The api2 vhost already carries `ServerAlias cxml` and `*.cxml`, so `POST http://cxml/` serves the
159
+ local api2 checkout and the router's host parser resolves the host part to `cxml` and takes the
160
+ cXML branch.
161
+ 2. `Core.DatabaseHosts` maps the `dev` environment to `localhost`, and `[api] _` points at
162
+ `http://api2/v2`, so internal API calls stay local.
163
+ 3. The **local** `vendor/`'s `platform_check.php` requires only PHP 7.2.5, so it boots on Laragon's
164
+ PHP 8.1.x (unlike the deployed beta bundle).
165
+ 4. **Insert the client's `Apis_RecordFields` overrides locally first.** A fresh local
166
+ `Client_<x>.Apis_RecordFields` is empty, so without them the test fails EV-12 for the *original*
167
+ provisioning reason and the change under test is never exercised.
168
+
169
+ ## Change history
170
+ - 2026-08-20 — Documented the cXML ShipNotice path as its own subject after a 67-day Compass Canada
171
+ outage. Root cause: nested tracking-number creation is governed by per-API
172
+ `Apis_RecordFields.overrideChildPolicy`, and Canada's cXML API had zero rows while the Core
173
+ defaults for 1441/1445 are `MATCH` (only 1437 is `MATCH_CREATE`) — every ASN rejected EV-12 on
174
+ `trackingNumber`. Also fixed two cross-tenant defects in `findOrCreateShippingCarrier()` (hardcoded
175
+ Compass USA auth wrote carriers into `Client_Compass`; the response was read from the singular
176
+ `shippingCarrier` key instead of `shippingCarriers`). Recorded the onboarding provisioning rule
177
+ (PC Matic B2B has the same gap today), the carrier `code` and nullable-`shippingMethodId`
178
+ constraints, the missing 4xx/5xx alerting, the `maxAuthsPerHour` replay pacing, and the local
179
+ Laragon test procedure. (bala)
180
+
181
+ ## Related docs
182
+ - [api2 architecture](../architecture.md) — host dispatch and the cXML branch.
183
+ - [Nested-relationship writes and child matching](nested-relationship-writes.md) — the
184
+ `Apis_RecordFields` override layer in full.
185
+ - [Tracking-number bridges](../../_underscore/features/tracking-number-bridges.md) — the bridge
186
+ records these policies apply to.
@@ -6,13 +6,14 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-17
9
+ updated: 2026-08-20
10
10
  owners: ["bala", "mhammontree", "tcox"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - _underscore/Model/Client/ContactEmailAddress.php
14
14
  related:
15
15
  - ../architecture.md
16
+ - cxml-shipnotice-gateway.md
16
17
  - ../../../clients/aig/features/entitlement-intake.md
17
18
  - ../../ai-bdr/features/web-funnel-app.md
18
19
  - ../../worker2/features/vapi-webhook-handler.md
@@ -169,6 +170,49 @@ columns matter:
169
170
  > Correct the data with an explicit `overrideIsIdentifier = 1`, or (systemic fix, not yet done)
170
171
  > change the code so only an explicit `0` removes an identifier and `NULL` means "no override".
171
172
 
173
+ ### `overrideChildPolicy` decides create-vs-lookup — and a MISSING row is a silent 100% outage
174
+
175
+ `getChildPolicy()` (`V2.php` ~8556) resolves the effective policy as
176
+ **`Client_<x>.Apis_RecordFields.overrideChildPolicy` for the authenticating API, falling back to
177
+ `Core.RecordFields.childPolicy`**. Two consequences that are easy to miss:
178
+
179
+ - **The same payload behaves differently per API within one tenant.** Provisioning a record and its
180
+ ACLs is not enough; the API doing the writing needs its own override rows.
181
+ - **`Core` defaults are not uniform across sibling fields**, so "it works for the other client" is
182
+ not evidence the metadata is right. The ASN tracking bridges are the canonical example:
183
+
184
+ | Bridge field | RecordField | Core default |
185
+ |---|---|---|
186
+ | ASN **unit** bridge `trackingNumberId` | 1437 | `MATCH_CREATE` |
187
+ | ASN **item** bridge `trackingNumberId` | 1441 | `MATCH` |
188
+ | ASN **header** bridge `trackingNumberId` | 1445 | `MATCH` |
189
+
190
+ Compass USA's cXML API (`apiId 4`) overrides all three to `MATCH_CREATE`; Compass Canada's cXML API
191
+ (`apiId 3`) had **zero** rows, so its nested `trackingNumber` was looked up instead of created and
192
+ **100% of its ship notices were rejected EV-12 on field `trackingNumber` for 67 days** (root-caused
193
+ 2026-08-20). Fixed by inserting the three override rows for `apiId 3`.
194
+
195
+ **Do not "fix" this class of bug by editing `Core.RecordFields`.** Core ids are identical in every
196
+ client DB, so flipping a base `childPolicy` to `MATCH_CREATE` grants mint capability to every tenant
197
+ at once — the reason the equivalent proposal on the IF-side field (2184) was rejected under CTO
198
+ review. Per-API overrides are the sanctioned lever. Full ASN case:
199
+ [cXML ShipNotice Gateway](cxml-shipnotice-gateway.md).
200
+
201
+ ### `MATCH_UPSERT` on a nested FK creates the related record — including deep grandchildren
202
+
203
+ `MATCH_UPSERT` means "match on an identifier, otherwise create". So a nested object two levels deep
204
+ is created by the same write. Measured on an ASN POST (prod, 2026-08-20): RecordField **335**
205
+ (`advance-shipping-notice-item-units.unitId`) is `MATCH_UPSERT`, so a nested `unit` that matches
206
+ nothing is **created** and linked to the ASN item unit — no separate `/units` call is needed, and
207
+ interceptor 55 then propagates it to `ItemFulfillmentItemUnits`.
208
+
209
+ The trap is one level deeper: **`units.itemId` is itself `MATCH_UPSERT`**. Referencing the item by a
210
+ non-identifier value (e.g. a `partNumber`) or by a mistyped uuid **silently creates a junk `Items`
211
+ row** rather than failing. Always reference an existing parent record **by `uuid`**, and resolve
212
+ business keys to uuids in the caller. (Also note `Units.itemId` is NOT NULL, so the item cannot be
213
+ omitted; and the `Units` identifier set is `uuid`/`itemId`/`serialNumber`/`assetTag`/`vendorId`/
214
+ `macAddress`, so item + serialNumber is what gives find-or-create on a device.)
215
+
172
216
  ### Fix it with a migration — a manual one-row data patch on non-prod DOES NOT SURVIVE
173
217
 
174
218
  The TRUE-79978 beta fix was applied by hand as a single `UPDATE` on 2026-07-09. The **identical
@@ -260,6 +304,16 @@ anywhere — no `messages[]`, nothing in the client `Logs` or the core/writer `L
260
304
 
261
305
  ## Change history
262
306
 
307
+ - 2026-08-20 — Documented that **`overrideChildPolicy` (not just `overrideIsIdentifier`) is per-API
308
+ provisioning**, and that a *missing* `Apis_RecordFields` row is a silent 100%-rejection outage:
309
+ Compass Canada's cXML API had zero rows and the Core defaults for the ASN **item**/**header**
310
+ tracking bridges (1441/1445) are `MATCH` while the **unit** bridge (1437) is `MATCH_CREATE`, so
311
+ every ship notice failed EV-12 on `trackingNumber` for 67 days. Added the rule that this class of
312
+ bug must be fixed with per-API overrides, never by editing `Core.RecordFields` (Core ids are shared
313
+ by every tenant). Also documented `MATCH_UPSERT` deep creation — a nested `unit` on an ASN POST is
314
+ created outright (RecordField 335), and because `units.itemId` is *also* `MATCH_UPSERT`, a
315
+ non-identifier or mistyped item reference silently creates a junk `Items` row, so parents must be
316
+ referenced by `uuid`. (bala)
263
317
  - 2026-08-17 (later pass) — Two new measured behaviors from the TRUE-80562 verification run on beta
264
318
  `Client_Aig`. (1) **CREATE-path re-parenting:** a nested forward singular FK
265
319
  (`primaryContactEmailAddress` / `primaryContactPhoneNumber`) is matched **tenant-wide by VALUE**,
@@ -6,10 +6,12 @@ project: API
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-07-20
10
- owners: ["jcardinal"]
9
+ updated: 2026-08-20
10
+ owners: ["jcardinal", "bala"]
11
11
  files:
12
12
  - api2/Config/<environment>.ini
13
+ - api2/composer.json
14
+ - api2/composer.lock
13
15
  - api2/Controller/Index.php
14
16
  - dbchanges2/Core/2026-06-16a - DatabaseHosts for new QA QC stage demo environments.sql
15
17
  - dbchanges2/Logs/
@@ -103,7 +105,38 @@ Diagnostic order:
103
105
  `Logs` schema was the *only* thing missing. Don't chase seed/config/connectivity when only base `Logs`
104
106
  is absent.
105
107
 
108
+ ## Edge case — a deployed `vendor/` built against the wrong PHP version kills the whole env
109
+
110
+ **Symptom (beta api2, live 2026-08-20): every request fails, with no routing at all.**
111
+ `vendor/composer/platform_check.php` demands **PHP >= 8.5.0** while the instance runs **8.1.12**, so
112
+ it fatals inside the composer autoload at `Controller/Index.php:41` — **before** any routing, logging
113
+ or error envelope. No Sentry event that looks like a request, nothing in `Logs.Api`.
114
+
115
+ **It is not a code bug and not a config bug — the deployed artifact does not match the repo.**
116
+ `composer.json` pins `php >= 8.2`, and `composer.lock` is **byte-identical** on `_beta` and
117
+ `_production` (same content-hash, `phpmailer v7.1.1`, nothing requiring 8.5). So the deployed
118
+ `vendor/` was **not built from the committed lock**.
119
+
120
+ Diagnosis order when an environment fails with *no* log line at all:
121
+ 1. `head vendor/composer/platform_check.php` on the instance and compare its required version
122
+ against `php -v`.
123
+ 2. Compare `composer.lock` content-hash across branches; if they match, suspect the build, not the
124
+ repo.
125
+
126
+ **Fix is DevOps, not application code:** raise the EB platform to at least PHP 8.2 **and** rebuild
127
+ `vendor/` with `composer install` (never `composer update`, which would re-resolve against the
128
+ builder's PHP and can re-introduce an 8.5 constraint). Until beta is repaired, gateway/API changes
129
+ have to be verified locally — see
130
+ [cXML ShipNotice Gateway → testing locally](../features/cxml-shipnotice-gateway.md).
131
+
106
132
  ## Change history
133
+ - 2026-08-20 — Recorded that **beta api2 is down at composer bootstrap**:
134
+ `vendor/composer/platform_check.php` requires PHP >= 8.5.0 while the instance runs 8.1.12, fataling
135
+ in `Controller/Index.php:41` before any routing, so all beta api2 requests fail with nothing
136
+ logged. `composer.json` pins `php >= 8.2` and `composer.lock` is byte-identical on `_beta` and
137
+ `_production`, so the deployed `vendor/` was not built from the committed lock. Fix is DevOps
138
+ (raise the EB platform to >= 8.2 and rebuild with `composer install`, not `update`). Added the
139
+ no-log-at-all diagnosis order. (bala)
107
140
  - 2026-07-20 — `sandbox-client` API 500'd on every request (Route.php:525 view fatal). Root cause: the
108
141
  base `Logs` schema (`DB_LOGS`, `Core.Databases` id 11; tables Api/Error/Webhook) was missing on the
109
142
  all-in-one cluster although every `Logs_<Client>` existed. `_Controller_Index::api()` queries
@@ -4,7 +4,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
4
4
 
5
5
  ## 1.0 framework
6
6
 
7
- - **library** (Library) _(framework core)_ — 18 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
7
+ - **library** (Library) _(framework core)_ — 19 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
8
  - **worker** (Worker) — 25 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
9
9
  - **dbchanges** (Database Changes) _(framework core)_ — 1 doc(s) → [1.0/apps/dbchanges/INDEX.md](1.0/apps/dbchanges/INDEX.md)
10
10
  - **worker1.5** (Worker 1.5) — 0 doc(s) → [1.0/apps/worker1.5/INDEX.md](1.0/apps/worker1.5/INDEX.md)
@@ -20,7 +20,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
20
20
 
21
21
  - **_underscore** (_Underscore) _(framework core)_ — 60 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 53 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
- - **api2** (API) — 24 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
23
+ - **api2** (API) — 25 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 8 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
25
25
  - **toga2-supply** (TOGa Supply) — 7 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
26
26
  - **saml** (SAML SSO Gateway) — 3 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
@@ -4,5 +4,6 @@
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
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. | 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/schedules/cron.worker.sync.json, _underscore/Model/Compass/AdvanceShippingNotice.php, _underscore/Model/Compass/Canada/AdvanceShippingNotice.php, dbchanges2/Client_CompassCanada/ |
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/ |
8
8
  | [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. | |
9
+ | [Grand & Toy ASN Backfill (cXML replay + CSV-to-JSON)](workflows/grand-and-toy-asn-backfill.md) | 2.0 | How to recover Compass Canada shipments whose ASN never landed — used on 2026-08-20 to backfill the 67-day Grand & Toy outage (**518 shipments**: 171 via cXML r | worker/crons/toga2/compasscanada/workflow_beta/import_grand_and_toy_asn_from_file.php, api2/Component/Api/Cxml/Cxml.php, _underscore/Model/Compass/AdvanceShippingNotice.php |
@@ -5,17 +5,22 @@ project: _Underscore
5
5
  client: compass-canada
6
6
  type: client-feature
7
7
  status: active
8
- updated: 2026-06-22
8
+ updated: 2026-08-20
9
9
  owners: ["bala"]
10
10
  files:
11
+ - api2/Component/Api/Cxml/Cxml.php
11
12
  - worker/crons/toga2/compasscanada/workflow/4_import_grand_and_toy_advance_shipping_notices.php
12
13
  - worker/crons/toga2/compasscanada/workflow/import_grand_and_toy_asn_from_file.php
14
+ - worker/crons/toga2/compasscanada/workflow_beta/import_grand_and_toy_asn_from_file.php
13
15
  - worker/schedules/cron.worker.sync.json
14
16
  - _underscore/Model/Compass/AdvanceShippingNotice.php
15
17
  - _underscore/Model/Compass/Canada/AdvanceShippingNotice.php
16
18
  - dbchanges2/Client_CompassCanada/
17
19
  related:
18
20
  - ../profile.md
21
+ - ../workflows/grand-and-toy-asn-backfill.md
22
+ - ../../../2.0/apps/api2/features/cxml-shipnotice-gateway.md
23
+ - ../../compass-usa/features/asn-to-item-fulfillment.md
19
24
  ---
20
25
 
21
26
  ## Summary
@@ -25,6 +30,40 @@ G&T's ASN CSV from a mailbox, posts each shipped line to the 2.0 API as an Advan
25
30
  an in-transit email in English or French. It mirrors the Compass USA ODP / Strategic-Systems ASN
26
31
  flow, adapted for Canadian carriers and bilingual email.
27
32
 
33
+ **The live path today is cXML, not the CSV cron.** G&T transmits ship notices to the api2 cXML
34
+ gateway (`cxml-writer`), which translates them into the same `POST /v2/advance-shipping-notices`.
35
+ The 24-column CSV cron below is **retired** — it remains documented because it is the only source of
36
+ serial numbers and the reference for the field mapping.
37
+
38
+ > ### Outage 2026-03 to 2026-08-20 — two stacked defects, 518 shipments lost
39
+ >
40
+ > **100% of G&T ship notices were rejected from 2026-06-11**, and most since March. Nothing alerted,
41
+ > because the cXML gateway emails nobody on a 4xx (the retired CSV cron did).
42
+ >
43
+ > 1. **Before 2026-06-11** the translator sent G&T's carrier **CODE** in a field matched against
44
+ > `ShippingCarriers.NAME`. Only `UPS` and `DHL` resolved (code happens to equal the name); every
45
+ > other carrier failed **EV-12 on field `shippingCarrier`**. So the feed was already losing most
46
+ > shipments while looking healthy.
47
+ > 2. **On 2026-06-11** the ShipControl rework (api2 commits `9259a10`, `8c987b0`) moved tracking onto
48
+ > the item/header bridges (RecordFields 1441/1445) and dropped the carrier from the header
49
+ > fallback. Those two bridge fields default to `childPolicy = MATCH` in Core and Compass Canada's
50
+ > cXML API had **no** `Apis_RecordFields` overrides, so the nested `trackingNumber` was looked up
51
+ > instead of created and every ASN failed **EV-12 on field `trackingNumber`**. Failure became
52
+ > total. Root-cause mechanics:
53
+ > [cXML ShipNotice Gateway](../../../2.0/apps/api2/features/cxml-shipnotice-gateway.md).
54
+ >
55
+ > Recovery procedure: [G&T ASN Backfill](../workflows/grand-and-toy-asn-backfill.md).
56
+
57
+ > ### ⚠ MIGRATION DEBT — the 2026-08-20 fixes exist ONLY in production
58
+ >
59
+ > The three `Client_CompassCanada.Apis_RecordFields` rows (`apiId 3`, `recordFieldId` 1437/1441/1445,
60
+ > `overrideChildPolicy = MATCH_CREATE`) and the carrier code/row changes were applied **directly to
61
+ > the production database**. There is **no `dbchanges2/Client_CompassCanada/` migration yet**, so
62
+ > beta, dev-sandbox, and any rebuilt or reseeded environment still fail for the original reason.
63
+ > Proven this session: the local `Client_CompassCanada.Apis_RecordFields` is empty and reproduced the
64
+ > June failure exactly. Write the migration before anything else touches this feed. (A manual
65
+ > non-prod patch also evaporates on the next tenant reseed — the same lesson as AIG TRUE-80562.)
66
+
28
67
  ## Key files / entry points
29
68
  - `worker/crons/toga2/compasscanada/workflow/4_import_grand_and_toy_advance_shipping_notices.php`
30
69
  — the scheduled cron (1.0 worker tier). Reads mailbox `compasscanada.status@togatech.com`
@@ -74,11 +113,84 @@ flow, adapted for Canadian carriers and bilingual email.
74
113
  - Interceptor wiring: `Core.RecordScripts` + `Core.ApiPayloadInterceptors`, `recordId 55`
75
114
  (AdvanceShippingNotice). The `sendEmail` POST record script is `Core.RecordScripts recordId 202`.
76
115
 
116
+ ## The cXML documents G&T actually sends
117
+
118
+ Measured across **all 1,074** stored documents in `Logs_CompassCanada.Api` — the shape never varies:
119
+
120
+ - **No `ShipControl` block at all.** The ShipControl parser never fires for this vendor. Carrier and
121
+ tracking arrive on `ShipNoticeHeader` as `CarrierIdentifier` > `CarrierName` and `TrackingID`.
122
+ - **No `Extrinsic` of any kind** — so no per-line tracking and no `TrackingURL`.
123
+ - **No serial numbers and no asset tags.**
124
+ - **All `operation="new"`.**
125
+ - **One `TrackingID` even when `TotalPackages > 1`.**
126
+
127
+ **Consequence:** the retired CSV feed was the only source of serial numbers, so ongoing cXML ASNs
128
+ create **no `Units`**. If Compass Canada expect per-device serial visibility, that needs a business
129
+ decision — it cannot be recovered from the cXML.
130
+
131
+ ## Carriers (cXML)
132
+
133
+ G&T's complete carrier code set, harvested from all 1,074 documents:
134
+
135
+ | Code | Carrier |
136
+ |---|---|
137
+ | `UPS` | UPS |
138
+ | `FDXH` | FedEx |
139
+ | `PUROL` | Purolator |
140
+ | `PRECI` | Precision |
141
+ | `NATEX` | Nationex |
142
+ | `GANDT` | Grand & Toy own van |
143
+ | `DHL` | DHL |
144
+ | `XXXX` | placeholder — its `TrackingID` is the literal string `DIRECT` |
145
+
146
+ Applied 2026-08-20: added the missing `code` values `PRECI` (Precision) and `NATEX` (Nationex), and
147
+ created a **Grand & Toy** carrier row in `Client_CompassCanada.ShippingCarriers`.
148
+
149
+ - `ShippingCarriers.code` holds **one** code per carrier and the lookup matches `code = X OR
150
+ name = X`, so several vendor codes cannot map onto one carrier without duplicate rows (this is why
151
+ `Client_Compass` has 7 separate FedEx rows).
152
+ - **No Ground `ShippingMethod` is needed for the cXML path.** `TrackingNumbers.shippingMethodId` is
153
+ nullable and cXML never sends a method. The "each carrier needs its own Ground method" rule below
154
+ came from the retired CSV cron only.
155
+
156
+ ## Serial numbers in the same ASN POST (no separate /units call)
157
+
158
+ A nested `unit` object inside the ASN POST creates the `Units` row outright: RecordField **335**
159
+ (`advance-shipping-notice-item-units.unitId`) is `MATCH_UPSERT`, so a unit that does not match is
160
+ created and linked to the ASNIU, and interceptor **55** then propagates it to
161
+ `ItemFulfillmentItemUnits`. Verified in prod (Units row created + linked, IFIU populated).
162
+
163
+ Constraints:
164
+ - `Units.itemId` is **NOT NULL**, so the item must be supplied.
165
+ - The `Units` identifiers are `uuid`, `itemId`, `serialNumber`, `assetTag`, `vendorId`, `macAddress`
166
+ — so **item + serialNumber** gives find-or-create on that pair.
167
+ - `units.itemId` is itself `MATCH_UPSERT`, so **reference the item by `uuid`, never by
168
+ `partNumber`** — a typo silently creates a junk `Items` row.
169
+ - Resolve the item **PO-scoped**: `PurchaseOrderItems` → `VendorItems.vendorPartNumber` →
170
+ `Items.uuid`, because the G&T "Part Number" column holds the **vendor** part number.
171
+
77
172
  ## Client variations
78
173
  Compass-Canada-specific; parallels Compass USA's ODP / Strategic-Systems ASN import. Canada sends
79
174
  a bilingual EN/FR in-transit email (USA is English only) and uses Canadian carriers.
80
175
 
81
176
  ## Gotchas / known issues
177
+ - **⚠ `PurchaseOrderItems_SalesOrderItems` is EMPTY across ALL 865 Compass Canada purchase orders —
178
+ this is NORMAL and must NOT be "repaired".** `_Model_Compass_AdvanceShippingNotice::postPost`
179
+ resolves PO to SO without that bridge for this client. Inserting bridge rows to make one ASN
180
+ succeed would change fulfilment behaviour for that PO. If an ASN fails, the cause is elsewhere
181
+ (child policy, carrier, or the already-tracked-line `EO-1` below).
182
+ - **⚠ Nothing alerts on a rejected ship notice.** The cXML gateway emails nobody on 4xx/5xx; the
183
+ retired CSV cron emailed `vburks@togatech.com` + `devTeam@togatech.com` on success, failure, wrong
184
+ column count and empty file. That asymmetry is why this outage ran 67 days unnoticed, and adding
185
+ alerting is the highest-value remaining fix. See the
186
+ [cXML gateway doc](../../../2.0/apps/api2/features/cxml-shipnotice-gateway.md).
187
+ - **A line whose only `ItemFulfillmentItem` already carries item-level tracking makes the ASN POST
188
+ return 500 `EO-1`, not a validation error.** `remaining = 0` routes to the reconcile path,
189
+ `itemFulfillmentItemHasTracking()` skips the IFI, and the handler then fails instead of completing
190
+ cleanly. Deterministic (reproduced three times, both transports; PO `50305097-1` line 4, Sentry
191
+ `AV-30` / `69-68`). It is in the **shared Compass parent**, so Office Depot re-shipments on
192
+ already-tracked lines hit it too — see
193
+ [Compass ASN to ItemFulfillment](../../compass-usa/features/asn-to-item-fulfillment.md).
82
194
  - **CSV "Part Number" is the VENDOR part number (`VendorItems.vendorPartNumber`), NOT
83
195
  `Items.partNumber` directly.** Resolve the item through the `VendorItems` join. (e.g. vendor
84
196
  `IMFP3260` → `Items.partNumber` `C2CY5UC#ABA`.)
@@ -116,6 +228,22 @@ a bilingual EN/FR in-transit email (USA is English only) and uses Canadian carri
116
228
  before `fetchOne()`.
117
229
 
118
230
  ## Change history
231
+ - 2026-08-20 — **Root-caused and fixed a 67-day, 100%-rejection outage on the G&T cXML ASN feed and
232
+ backfilled 518 shipments.** Root cause: nested tracking-number creation is governed by the
233
+ per-API `Client_<x>.Apis_RecordFields.overrideChildPolicy`, and Canada's "Grand & Toy (Cxml)" API
234
+ (`apiId 3`) had **zero** rows while the Core defaults for RecordFields 1441 (item bridge) and 1445
235
+ (header bridge) are `MATCH` (only 1437 is `MATCH_CREATE`) — so every ASN failed EV-12 on
236
+ `trackingNumber` after the 2026-06-11 ShipControl rework moved tracking onto those bridges. Before
237
+ that date a separate defect sent the carrier CODE into a NAME match, so only UPS and DHL resolved.
238
+ Applied to prod: three `Apis_RecordFields` `MATCH_CREATE` rows, the missing `PRECI`/`NATEX` carrier
239
+ codes, and a Grand & Toy carrier row. Also fixed the cross-tenant `findOrCreateShippingCarrier()`
240
+ defect in api2 (carriers for other tenants were written into `Client_Compass`). Backfilled 518
241
+ shipments (536 ASNs / 512 TrackingNumbers / 406 ItemFulfillments / 332 Units). Documented G&T's
242
+ invariant cXML shape (no ShipControl, no Extrinsic, no serials, one TrackingID per shipment), the
243
+ in-POST serial-number path (RecordField 335 `MATCH_UPSERT`), the empty
244
+ `PurchaseOrderItems_SalesOrderItems` invariant, the missing gateway alerting, and the outstanding
245
+ `EO-1` already-tracked-line bug. **⚠ All DB changes are prod-only — no dbchanges2 migration yet.**
246
+ (bala)
119
247
  - 2026-06-22 — Full variable rename (no abbreviations); moved PO/carrier/shipping-method lookup maps
120
248
  outside the CSV row loop (was causing N full-table queries per file); fixed ASNIU→TN link to run
121
249
  unconditionally for new ASNIUs (not only when TN was new); added PHP 8 null-coalesce on carrier→
@@ -127,3 +255,6 @@ a bilingual EN/FR in-transit email (USA is English only) and uses Canadian carri
127
255
 
128
256
  ## Related docs
129
257
  - [Compass Canada profile](../profile.md)
258
+ - [G&T ASN Backfill workflow](../workflows/grand-and-toy-asn-backfill.md)
259
+ - [cXML ShipNotice Gateway (api2)](../../../2.0/apps/api2/features/cxml-shipnotice-gateway.md)
260
+ - [Compass ASN to ItemFulfillment (shared handler)](../../compass-usa/features/asn-to-item-fulfillment.md)
@@ -16,11 +16,14 @@ project: _Underscore
16
16
  client: compass-canada
17
17
  type: profile
18
18
  status: active
19
- updated: 2026-08-17
19
+ updated: 2026-08-20
20
20
  owners: [jcardinal, bala, tcox, apeterson]
21
21
  files: []
22
22
  related:
23
23
  - ../compass-usa/profile.md
24
+ - features/grand-and-toy-asn-import.md
25
+ - workflows/grand-and-toy-asn-backfill.md
26
+ - ../../2.0/apps/api2/features/cxml-shipnotice-gateway.md
24
27
  - ../compass-usa/features/approval-decision-flow.md
25
28
  - ../compass-usa/features/isfulfillable-data-quality-and-type-rule.md
26
29
  - features/french-order-email-localization.md
@@ -47,8 +50,21 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
47
50
  - **Grand & Toy (G&T)** — primary hardware vendor. SOs flow toga → MITS → PO to G&T; G&T sends
48
51
  back ASNs. ASN ingestion (email CSV + the auto-created ItemFulfillment chain + bilingual
49
52
  in-transit email) is documented in [Grand & Toy ASN Import](features/grand-and-toy-asn-import.md).
50
- Vendor uuid: `App_Client_CompassCanada::UUID_VENDOR__GRAND_TOY`. Canadian carriers in use:
51
- UPS, FedEx, Purolator, Precision, Nationex, ATSL (each with a Ground `ShippingMethod`).
53
+ Vendor uuid: `App_Client_CompassCanada::UUID_VENDOR__GRAND_TOY`. **The live ASN path is cXML**
54
+ (api2 `cxml-writer`), not the retired 24-column CSV cron; carrier codes G&T sends are `UPS`,
55
+ `FDXH`, `PUROL`, `PRECI`, `NATEX`, `GANDT`, `DHL`, `XXXX` (placeholder, TrackingID = `DIRECT`).
56
+ The cXML path needs **no** Ground `ShippingMethod` per carrier (`TrackingNumbers.shippingMethodId`
57
+ is nullable and cXML sends no method) — that requirement came from the CSV cron only.
58
+ - **⚠ The G&T ASN feed rejected 100% of ship notices for 67 days (2026-06-11 → 2026-08-20) and
59
+ nothing alerted.** Root cause: nested tracking-number creation is governed by
60
+ `Client_CompassCanada.Apis_RecordFields.overrideChildPolicy` per API, and the "Grand & Toy (Cxml)"
61
+ API (`apiId 3`) had zero rows. 518 shipments were backfilled. **The fix is prod-only — there is no
62
+ `dbchanges2/Client_CompassCanada/` migration yet, so beta / dev-sandbox / any reseed still fails.**
63
+ See [G&T ASN Import](features/grand-and-toy-asn-import.md) and the
64
+ [backfill workflow](workflows/grand-and-toy-asn-backfill.md).
65
+ - **`PurchaseOrderItems_SalesOrderItems` is empty for all 865 Canada POs — that is normal for this
66
+ client.** `postPost` resolves PO to SO without it; never insert bridge rows to "repair" an ASN
67
+ failure here.
52
68
  - Worker crons for the G&T flow live under `worker/crons/toga2/compasscanada/workflow/`
53
69
  (`1_…` transmit SOs to MITS, `2_…` transmit POs to vendors, `3_…` status from G&T cXML,
54
70
  `4_…` import G&T ASNs).
@@ -0,0 +1,101 @@
1
+ ---
2
+ title: Grand & Toy ASN Backfill (cXML replay + CSV-to-JSON)
3
+ framework: "2.0"
4
+ project: _Underscore
5
+ client: compass-canada
6
+ type: workflow
7
+ status: active
8
+ updated: 2026-08-20
9
+ owners: ["bala"]
10
+ files:
11
+ - worker/crons/toga2/compasscanada/workflow_beta/import_grand_and_toy_asn_from_file.php
12
+ - api2/Component/Api/Cxml/Cxml.php
13
+ - _underscore/Model/Compass/AdvanceShippingNotice.php
14
+ related:
15
+ - ../features/grand-and-toy-asn-import.md
16
+ - ../profile.md
17
+ - ../../../2.0/apps/api2/features/cxml-shipnotice-gateway.md
18
+ ---
19
+
20
+ ## Summary
21
+
22
+ How to recover Compass Canada shipments whose ASN never landed — used on 2026-08-20 to backfill the
23
+ 67-day Grand & Toy outage (**518 shipments**: 171 via cXML replay + 347 via JSON; result **536 ASNs,
24
+ 512 TrackingNumbers, 406 ItemFulfillments, 332 Units**). Two complementary sources, because neither
25
+ alone is complete: the stored cXML documents cover what G&T actually transmitted, and the G&T CSV
26
+ covers everything else (and is the only source of serial numbers).
27
+
28
+ **Run the root-cause fix first.** A backfill against an unprovisioned tenant just re-creates the
29
+ original EV-12 rejections — see
30
+ [G&T ASN Import](../features/grand-and-toy-asn-import.md) and the
31
+ [cXML gateway provisioning rule](../../../2.0/apps/api2/features/cxml-shipnotice-gateway.md).
32
+
33
+ ## Route A — replay the stored cXML
34
+
35
+ 1. **Source.** Every rejected document is retained complete in `Logs_CompassCanada.Api`
36
+ (`direction = IN`, `route = '/'`, `method = POST`). Nothing was lost by the outage.
37
+ 2. **Dedupe** on `shipmentID` + `orderID` + `TrackingID`. G&T re-sends, and the log holds every
38
+ attempt.
39
+ 3. **Re-POST** the document body verbatim to `cxml-writer.togahub.com` as `text/xml`.
40
+ The `Sender` credential is **inside the document**, so no `Authorization` header is needed or read
41
+ — never extract or handle the credential.
42
+ 4. **Pace it ~20 seconds per document.** `Apis.maxAuthsPerHour` is `255` and the gateway
43
+ authenticates once per document.
44
+
45
+ ## Route B — build JSON ASNs from the G&T CSV
46
+
47
+ 1. POST to `/v2/advance-shipping-notices` with a Bearer token (one token for the whole run; no
48
+ pacing needed, the auth limit does not apply).
49
+ 2. **The payload must carry all three tracking bridges** or the partial in-transit email loses its
50
+ per-package item breakdown:
51
+ - `advanceShippingNoticeTrackingNumbers` (header)
52
+ - `advanceShippingNoticeItemTrackingNumbers` (item)
53
+ - `advanceShippingNoticeItemUnits[].advanceShippingNoticeItemUnitTrackingNumbers` (unit)
54
+ 3. Serial numbers can be created **inside the same POST** (nested `unit` object) — no separate
55
+ `/units` call. Resolve the item **PO-scoped** through `PurchaseOrderItems` →
56
+ `VendorItems.vendorPartNumber` → `Items.uuid`, because the G&T "Part Number" column holds the
57
+ **vendor** part number, and reference the item **by uuid only**. See the ASN-import doc's
58
+ serial-number section for the constraints.
59
+
60
+ ## Dedupe and safety guards
61
+
62
+ - **`TrackingNumbers.number` carries a UNIQUE key**, so a replayed tracking number `MATCH`es rather
63
+ than duplicating. **But a repeated document still creates a duplicate ASN.** Always skip any
64
+ shipment whose tracking number already exists before posting.
65
+ - **Suppress stale customer emails.** Stamp `TrackingNumbers.c_dtInTransitEmailSent`, keyed off
66
+ `AdvanceShippingNotices.dateShipped` older than **3 days**, so a genuinely fresh shipment still
67
+ emails the customer while months-old backfilled shipments stay silent.
68
+ - Verify by row count afterwards (ASNs / TrackingNumbers / ItemFulfillments / Units), never by HTTP
69
+ status — a 201 only proves the parent was written.
70
+
71
+ ## Systems involved
72
+
73
+ - `Logs_CompassCanada.Api` (cXML source of truth), `cxml-writer.togahub.com` (gateway),
74
+ `/v2/advance-shipping-notices` (JSON route), `Client_CompassCanada` (ASN/IF/Units/TrackingNumbers),
75
+ the shared `_Model_Compass_AdvanceShippingNotice::postPost` interceptor.
76
+ - Script: `worker/crons/toga2/compasscanada/workflow_beta/import_grand_and_toy_asn_from_file.php`.
77
+
78
+ ## Edge cases
79
+
80
+ - **A line whose only ItemFulfillmentItem already carries item-level tracking makes `postPost`
81
+ return 500 `EO-1`** (not a validation error) — the reconcile path skips the IFI and the handler
82
+ then fails instead of completing. Deterministic; hit three times across both transports (PO
83
+ `50305097-1` line 4; Sentry `AV-30`, `69-68`). Skip and record those shipments; the open bug is
84
+ tracked on the [shared Compass ASN handler doc](../../compass-usa/features/asn-to-item-fulfillment.md).
85
+ - **G&T `XXXX` carrier code** carries the literal `DIRECT` as its `TrackingID` — it is a placeholder,
86
+ not a real tracking number.
87
+ - **One `TrackingID` even when `TotalPackages > 1`** — do not synthesize per-package numbers.
88
+
89
+ ## Change history
90
+ - 2026-08-20 — Documented the two-route backfill after recovering the 67-day G&T outage (518
91
+ shipments / 536 ASNs / 512 TrackingNumbers / 406 ItemFulfillments / 332 Units). Recorded the cXML
92
+ replay source and the ~20s `maxAuthsPerHour` pacing, the three-bridge JSON payload requirement,
93
+ the tracking-number-exists dedupe guard, the `c_dtInTransitEmailSent` + 3-day email suppression,
94
+ and the `EO-1` already-tracked-line edge case. (bala)
95
+
96
+ ## Related docs
97
+ - [Grand & Toy ASN Import](../features/grand-and-toy-asn-import.md) — the ongoing feed and its root
98
+ cause history.
99
+ - [cXML ShipNotice Gateway](../../../2.0/apps/api2/features/cxml-shipnotice-gateway.md) — provisioning
100
+ and replay mechanics.
101
+ </content>
@@ -5,7 +5,7 @@ project: _Underscore
5
5
  client: compass-usa
6
6
  type: client-feature
7
7
  status: active
8
- updated: 2026-07-02
8
+ updated: 2026-08-20
9
9
  owners: [jcardinal, bala]
10
10
  files:
11
11
  - _underscore/Model/Compass/AdvanceShippingNotice.php
@@ -20,6 +20,8 @@ files:
20
20
  - dbchanges2/Client_Compass/2026-06-18b - CleanupSA132881DuplicateTracking.sql
21
21
  - dbchanges2/Client_Compass/2026-07-02a - FixSA133377TrackingSerialAndDuplicateIF.sql
22
22
  related:
23
+ - ../../../2.0/apps/api2/features/cxml-shipnotice-gateway.md
24
+ - ../../compass-canada/features/grand-and-toy-asn-import.md
23
25
  - ../../../2.0/apps/_underscore/features/recursive-item-fulfillments.md
24
26
  - ../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
25
27
  - ../../../2.0/apps/_underscore/features/model-save-parent-cascade-stored-field-deadlock.md
@@ -111,6 +113,29 @@ Compass Canada (`Model/Compass/Canada/`) is a separate sub-client. The Compass c
111
113
  (Core `ClientApiIdentities` identity `153531108`, clientId 2) holds roles 1 and 3.
112
114
 
113
115
  ## Gotchas / known issues
116
+ - **⚠ OPEN BUG — an already-tracked, fully-fulfilled line makes `postPost` return 500 `EO-1`
117
+ (2026-08-20).** When an ASN item resolves to a SalesOrderItem whose **only**
118
+ `ItemFulfillmentItem` already carries item-level tracking, `remaining = 0` routes to the reconcile
119
+ path, `itemFulfillmentItemHasTracking()` (added 2026-06-18) skips that IFI, and the handler then
120
+ **fails** instead of completing cleanly — so the caller gets an operation error, not the intended
121
+ no-op, and the ASN is lost. Deterministic: reproduced three times across both the cXML and the JSON
122
+ transports (repro: PO `50305097-1` line 4; Sentry ids `AV-30`, `69-68`). Found on Compass Canada
123
+ but it lives in the **shared Compass parent**, so Office Depot re-shipments on already-tracked
124
+ lines hit it too. This recurs whenever a vendor sends a second package for a line that already has
125
+ tracking. Fix direction: the skip should complete the request successfully (nothing to attach), not
126
+ fall through to the failure path.
127
+ - **The cXML API needs `MATCH_CREATE` overrides on all three ASN tracking bridges.** Compass USA's
128
+ cXML API (`apiId 4`) has `Client_Compass.Apis_RecordFields` rows overriding RecordFields
129
+ **1437/1441/1445** to `MATCH_CREATE`. Only 1437 defaults to `MATCH_CREATE` in Core, so without the
130
+ 1441/1445 overrides the nested `trackingNumber` is looked up instead of created and every ship
131
+ notice fails EV-12 — exactly what happened to Compass Canada for 67 days. Do not delete or
132
+ "normalize" these rows. See
133
+ [cXML ShipNotice Gateway](../../../2.0/apps/api2/features/cxml-shipnotice-gateway.md).
134
+ - **`findOrCreateShippingCarrier()` was writing other tenants' carriers into `Client_Compass`
135
+ (fixed 2026-08-20, not yet committed).** The cXML helper authenticated its carrier-create call with
136
+ hardcoded Compass USA credentials, so Compass **Canada** traffic minted carriers here: 15 `PRECI`,
137
+ 5 `GANDT` and 3 `XXXX` junk `ShippingCarriers` rows in `Client_Compass` are **not** Compass USA
138
+ data and should be cleaned up.
114
139
  - **ASN arriving AFTER the SO is already fulfilled (fixed 2026-06-15):** when an SO is fulfilled
115
140
  out-of-band (NetSuite/toga2-supply creates `F`-numbered IFs) **before** its ASN arrives,
116
141
  `postPost` previously found zero unfulfilled ASN items and skipped the **entire** propagation
@@ -221,6 +246,15 @@ Compass Canada (`Model/Compass/Canada/`) is a separate sub-client. The Compass c
221
246
 
222
247
  ## Change history
223
248
  Dated one-liners, newest first.
249
+ - 2026-08-20 — Recorded an **open, deterministic 500 `EO-1`** in `postPost`: an ASN item whose
250
+ SalesOrderItem has one already-tracked `ItemFulfillmentItem` takes the reconcile path, the IFI is
251
+ skipped by `itemFulfillmentItemHasTracking()`, and the handler then fails rather than completing —
252
+ so a vendor's second package on an already-tracked line errors instead of no-op'ing. Found while
253
+ fixing the Compass Canada G&T cXML feed (shared parent, so Office Depot is exposed too). Also
254
+ recorded that Compass USA's cXML API (`apiId 4`) relies on `Apis_RecordFields` `MATCH_CREATE`
255
+ overrides for RecordFields 1437/1441/1445 (Core defaults 1441/1445 to `MATCH`), and that the
256
+ cross-tenant `findOrCreateShippingCarrier()` defect left 23 junk `Client_Compass.ShippingCarriers`
257
+ rows created by Compass Canada traffic. (bala)
224
258
  - 2026-08-13 — Diagnosed the recurring MySQL 1213 deadlocks on `POST /v2/advance-shipping-notices`
225
259
  (Office Depot cXML feed): 100% were the `_total` UPDATE emitted by the `_Model::save()` parent
226
260
  cascade re-saving the PurchaseOrder on each ASN insert, deadlocking under concurrent same-PO ASN
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.620",
3
+ "version": "1.0.622",
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",