toga-ai 1.0.772 → 1.0.774

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/knowledge/1.0/apps/tools/INDEX.md +1 -0
  2. package/knowledge/1.0/apps/tools/architecture.md +12 -3
  3. package/knowledge/1.0/apps/tools/features/compass-user-persona-admin.md +138 -0
  4. package/knowledge/1.0/apps/tools/features/mvc-data-access-patterns.md +36 -2
  5. package/knowledge/1.0/apps/tools/features/saml-sso-auth.md +19 -2
  6. package/knowledge/1.0/apps/worker/INDEX.md +1 -1
  7. package/knowledge/1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md +156 -11
  8. package/knowledge/1.0/apps/worker/workflows/tracing-a-worker-cron-run-in-production.md +22 -1
  9. package/knowledge/2.0/apps/_underscore/INDEX.md +1 -0
  10. package/knowledge/2.0/apps/_underscore/features/effective-persona-resolution.md +88 -0
  11. package/knowledge/2.0/apps/_underscore/features/record-change-audit-log.md +18 -3
  12. package/knowledge/2.0/apps/dbchanges2/features/rerunnable-additive-inserts.md +14 -5
  13. package/knowledge/2.0/apps/worker2/features/all-client-email-queue-monitor.md +15 -2
  14. package/knowledge/2.0/apps/worker2/features/oneuptime-worker2-monitoring.md +68 -6
  15. package/knowledge/2.0/apps/worker2/features/tracking-status-refresh.md +45 -1
  16. package/knowledge/INDEX.md +2 -2
  17. package/knowledge/clients/compass-canada/INDEX.md +2 -2
  18. package/knowledge/clients/compass-canada/features/french-order-email-localization.md +2 -2
  19. package/knowledge/clients/compass-canada/features/grand-and-toy-asn-import.md +2 -2
  20. package/knowledge/clients/compass-canada/features/order-fulfillment-status-per-line.md +1 -1
  21. package/knowledge/clients/compass-canada/profile.md +3 -2
  22. package/knowledge/clients/compass-usa/INDEX.md +1 -0
  23. package/knowledge/clients/compass-usa/features/contact-email-resolution.md +149 -0
  24. package/knowledge/clients/compass-usa/features/order-fulfillment-status-per-line.md +1 -1
  25. package/knowledge/clients/compass-usa/features/people-file-user-lifecycle.md +35 -5
  26. package/knowledge/clients/compass-usa/features/persona-model-and-levy-gating.md +16 -2
  27. package/knowledge/clients/compass-usa/profile.md +3 -2
  28. package/knowledge/clients/compass-usa/workflows/granting-persona-bundle-access.md +15 -2
  29. package/package.json +1 -1
@@ -6,6 +6,7 @@
6
6
  | [/clickup/aliases — ClickUp Label-Alias Admin (Tools → 2.0 Team schema)](features/clickup-label-aliases-admin.md) | The human side of the NetSuite→ClickUp **Stakeholders / End Customer** labels. | tools/mvc/clickup/aliases/get.php, tools/mvc/clickup/aliases/post.php, tools/_/app/clickup/aliases.php, tools/_/app/nav.php |
7
7
  | [Developer Dashboard (ClickUp Sprint, tools /developer)](features/clickup-sprint-dashboard.md) | A native **ClickUp sprint dashboard** in the 1.0 `tools` app at route `/developer` (the **"Developer Dashboard"**, renamed from `/clickup/react` / "Clickup" — s | tools/_/app/clickup/sprint.php, tools/v2/sprints/tile/index.php, tools/v2/sprints/current/index.php, tools/v2/sprints/status-breakdown/index.php, tools/v2/sprints/worktype-breakdown/index.php, tools/v2/sprints/points-by-dev/index.php, tools/v2/sprints/burndown/index.php, tools/assets/clickup/sprint-dashboard.html, tools/mvc/developer/get.php, tools/mvc/login/get.php, tools/_/app/nav.php |
8
8
  | [CloudFront Client Setup](features/cloudfront-client-setup.md) | An SSO-gated admin tool at **`/devops/cloudfront-clients`** in the Tools 1.0 app that onboards a client onto **CloudFront + Route 53 across multiple AWS account | tools/_/app/devops/cloudfront.php, tools/mvc/devops/cloudfront-clients/get.php, tools/mvc/devops/cloudfront-clients/post.php, tools/assets/js/cloudfront-clients.js, tools/assets/css/cloudfront-clients.css, tools/_/app/nav.php, tools/_/app/frameworkindex.php, tools/config.production.ini |
9
+ | [Compass User & Persona Admin (tools /compass/users, /compass/personas)](features/compass-user-persona-admin.md) | Two pages in the Tools `developers` nav folder that let staff do, in a browser, the two Compass requests that used to mean "email a developer, who writes a `dbc | tools/_/app/compass.php, tools/mvc/compass/users/get.php, tools/mvc/compass/users/post.php, tools/mvc/compass/personas/get.php, tools/mvc/compass/personas/post.php, tools/_/app/nav.php, tools/config.production.ini |
9
10
  | [Design Demo Admin](features/design-demo-admin.md) | A self-serve admin UI at **`/design`** in the SSO-protected **Tools** app that lets the design team publish self-contained "Claude Design" HTML exports as **ver | tools/_/app/design/github.php, tools/mvc/design/get.php, tools/mvc/design/post.php, tools/assets/css/design.css, tools/assets/js/design.js, tools/_/app/frameworkindex.php, tools/_/app/nav.php, tools/composer.json |
10
11
  | [Tools — Developers Folder (UUID & Password Generators)](features/developer-tools.md) | The first two tools shipped in the Tools app, both under the **Developers** folder and gated to personas **Development Team** / **TOGa Technology**. | tools/mvc/developers/uuid/get.php, tools/mvc/developers/password/get.php, tools/mvc/developers/password/post.php |
11
12
  | [/errors Curation Console (Tools → shared Core Logs DB)](features/errors-curation-console.md) | Internal-only triage/curation screen for the 2.0 Issue/Event error-reporting pipeline, built as a 1.0 Tools MVC page reading the **shared Core Logs DB** through | tools/mvc/errors/get.php, tools/mvc/errors/post.php, tools/mvc/errors/issue/get.php, tools/assets/css/style.css, tools/_/app/nav.php, tools/config.production.ini |
@@ -6,8 +6,8 @@ project: Tools
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-08-14
10
- owners: [jcardinal, kyalamarthi]
9
+ updated: 2026-09-04
10
+ owners: [jcardinal, kyalamarthi, ajean]
11
11
  files:
12
12
  - tools/index.php
13
13
  - tools/.ebextensions/020_setup_git_libraries.config
@@ -41,7 +41,11 @@ related:
41
41
  **Tools** is a standalone 1.0 (`App_`) application that houses many small internal tools behind
42
42
  simple interfaces, gated by Client_True staff persona. It is modeled on `togaview` and depends on
43
43
  the `library` core. Auth comes via the SAML gateway `?saml=` handoff; the app owns its own
44
- session and reads `Client_True` **read-only**. Adding a tool = create `mvc/<route>/get.php`
44
+ session and reads `Client_True` **read-only** — but it is **no longer a read-only app**: as of
45
+ PR tools#13 (unmerged) it also **WRITES** to client tenant databases (`Client_Compass` /
46
+ `Client_CompassCanada`) through new `[database_compass]` / `[database_compasscanada]` ini
47
+ sections that reuse the `[database_true]` account. That is the first non-read-only surface in
48
+ Tools. Adding a tool = create `mvc/<route>/get.php`
45
49
  (copy `mvc/_TEMPLATE`) + add one `App_Nav::definition()` entry + `App_Auth::requireAuth([...])`.
46
50
 
47
51
  **Critical rules:** Do NOT use bare `<header>` elements in this app — they inherit the legacy
@@ -59,6 +63,11 @@ denied at the front controller / out of the web root. New CSS must use `--c-*` t
59
63
  `App_FrameworkIndex::setHeaderCode()` is the **only** hook for markup in `<head>`, and it
60
64
  **SETS rather than appends** — the theme bootstrap is its sole caller, so a second call
61
65
  silently clobbers it.
66
+ **Any page that writes to a client tenant DB must `App_Auth::requireAuth()` an explicit, narrow
67
+ persona list (`['Development Team']`) — never `App_Nav::personasForRoute()`**, which returns the
68
+ UNION of the folder's personas and the action's personas and so widens the grant straight back to
69
+ everyone who can see the nav group. Also: **persona checks read a 4-day session snapshot**, so
70
+ removing someone's persona does **not** revoke their access — their session must be killed.
62
71
 
63
72
  ## Boot & structure
64
73
 
@@ -0,0 +1,138 @@
1
+ ---
2
+ title: Compass User & Persona Admin (tools /compass/users, /compass/personas)
3
+ framework: "1.0"
4
+ repo: tools
5
+ project: Tools
6
+ client: shared
7
+ type: feature
8
+ status: draft
9
+ updated: 2026-09-04
10
+ owners: [ajean]
11
+ files:
12
+ - tools/_/app/compass.php
13
+ - tools/mvc/compass/users/get.php
14
+ - tools/mvc/compass/users/post.php
15
+ - tools/mvc/compass/personas/get.php
16
+ - tools/mvc/compass/personas/post.php
17
+ - tools/_/app/nav.php
18
+ - tools/config.production.ini
19
+ related:
20
+ - ./mvc-data-access-patterns.md
21
+ - ./persona-gated-navigation.md
22
+ - ./saml-sso-auth.md
23
+ - ../architecture.md
24
+ - ../../../2.0/apps/_underscore/features/effective-persona-resolution.md
25
+ - ../../../clients/compass-usa/features/people-file-user-lifecycle.md
26
+ - ../../../clients/compass-usa/features/persona-model-and-levy-gating.md
27
+ - ../../../clients/compass-usa/workflows/granting-persona-bundle-access.md
28
+ ---
29
+
30
+ ## Summary
31
+
32
+ Two pages in the Tools `developers` nav folder that let staff do, in a browser, the two Compass
33
+ requests that used to mean "email a developer, who writes a `dbchanges2` SQL file": **activate /
34
+ deactivate a Compass user**, and **grant a persona**. Both tenants are covered by one tenant
35
+ switcher — **US** (`Client_Compass`) and **Canada** (`Client_CompassCanada`).
36
+
37
+ **This is the first thing in `tools` that WRITES to a client tenant database.** Until now the app
38
+ read `Client_True` read-only. That single fact drives most of the design and every open risk below.
39
+
40
+ Status: built on PR **tools#13**, **not yet merged**. `status: draft` until it ships.
41
+
42
+ ## Key files / entry points
43
+
44
+ - **`tools/_/app/compass.php`** — `App_Compass`, all data access for both pages (search, persona
45
+ resolution, activate/deactivate, grant).
46
+ - **`tools/mvc/compass/users/{get,post}.php`** — user search + activate/deactivate + grant persona.
47
+ - **`tools/mvc/compass/personas/{get,post}.php`** — inspect one persona and who reaches it.
48
+ - **`tools/_/app/nav.php`** — the two nav entries, inside the existing `developers` folder.
49
+ - **`tools/config.production.ini`** — new `[database_compass]` / `[database_compasscanada]` sections.
50
+
51
+ ## How it works
52
+
53
+ ### What the pages do
54
+
55
+ 1. **Find a user** by `c_hrEmpUsername`, `email`, or `c_hrEmpPersonnelNbr`.
56
+ 2. **Activate / deactivate** them (`Users.isActive`).
57
+ 3. **See the personas that user reaches, and WHY** — direct row, own location, or sector.
58
+ 4. **Inspect a persona** and list who reaches it, from all three sources.
59
+ 5. **Grant a persona** — one `INSERT` into `Users_Personas`.
60
+
61
+ The "why" column is the point of the tool. Answering "who has persona N" from `Users_Personas`
62
+ alone badly under-reports on Compass, because most users hold their catalogue through their
63
+ **sector**, not through a row of their own. The resolution rule the tool implements is the
64
+ framework's own — see
65
+ [Effective persona resolution](../../../2.0/apps/_underscore/features/effective-persona-resolution.md),
66
+ which also carries the measured prod numbers and the "reverse lookups must be set-based" rule.
67
+
68
+ ### Access gating is deliberately tighter than the folder
69
+
70
+ Both pages `requireAuth(['Development Team'])` — narrower than the `developers` folder's own
71
+ persona set. That is on purpose: these are tenant **writes**, not read-only developer utilities.
72
+ Passing `App_Nav::personasForRoute()` here would widen the grant back to the folder's set; see the
73
+ nav-narrowing gotcha in [MVC data access patterns](./mvc-data-access-patterns.md).
74
+
75
+ ### Database connections
76
+
77
+ New `[database_compass]` and `[database_compasscanada]` sections in `config.production.ini` reuse the
78
+ **existing `[database_true]` account** — same client cluster, only `dbname` differs. **No new secret
79
+ value was introduced.** See the accepted risk below.
80
+
81
+ ### Schema footprint: none
82
+
83
+ The tool creates and alters **nothing**. It reads/writes only `Users`, `Users_Personas`,
84
+ `Locations_Personas`, `Locations`, and reads `Personas` for names.
85
+
86
+ ## Decisions
87
+
88
+ - **2026-09-04 — No new columns, no new tables (Jeff Cardinal, back-end review).** The tool talks
89
+ directly to production `Client_Compass` / `Client_CompassCanada` using the existing schema only.
90
+ This killed an earlier design that added `Users.c_isKeepActive` plus expiry/audit columns, an
91
+ `AdminAuditLogs` table, and three search indexes. `dbchanges2#478` and `worker2#160` were opened
92
+ and then **closed unmerged** — do not resurrect them without re-opening that decision.
93
+ - **2026-09-04 — Reuse the `[database_true]` credential** rather than split out a least-privilege
94
+ Compass account. Raised twice in security review and consciously declined for now (see below).
95
+
96
+ ## Gotchas
97
+
98
+ - **⚠ Activating a Compass user is TEMPORARY; deactivating is durable.** Because the
99
+ `c_isKeepActive` flag was dropped from scope, the nightly PEOPLE importer will deactivate anyone
100
+ absent from that day's file — so an activation only sticks if the person is actually in the file.
101
+ Deactivation holds, because the cron never reactivates anybody. Full lifecycle:
102
+ [PEOPLE-file User Lifecycle](../../../clients/compass-usa/features/people-file-user-lifecycle.md).
103
+ - **A cross-region user still needs a code change and a deploy.** The only durable way to hold one
104
+ active is the hardcoded four-address list in `ensureCrossRegionUsersActive()` in
105
+ `worker2/Worker/Client/Compass/PeopleFile.php`. This tool does not change that.
106
+ - **Every user search is a full table scan.** `Client_Compass.Users` has no index on `email`,
107
+ `c_hrEmpUsername`, `c_hrEmpPersonnelNbr` or `isActive` (176k rows; `EXPLAIN` reports `type=ALL`,
108
+ no possible keys). Same on `Client_CompassCanada`. Keep result sets bounded and do not add
109
+ per-row lookups against those columns in a loop.
110
+ - **⚠ Accepted risk — the tool widens the blast radius of one shared credential.** It points the
111
+ `[database_true]` account at two more tenant schemas. `config.production.ini` is committed and was
112
+ web-readable until the 2026-07-24 `FilesMatch` deny blocks, so **rotation of that account is still
113
+ owed**. An inline comment in the ini records this and lists the exact grants a split-out Compass
114
+ account would need. Never paste the value anywhere.
115
+ - **⚠ Persona revocation does not take effect for up to 4 days.** Tools snapshots persona names into
116
+ `$_SESSION` at login and sessions last 4 days, so removing someone's `Development Team` persona
117
+ does not lock them out of these pages until their session dies. See
118
+ [SAML SSO & persona-gated auth](./saml-sso-auth.md).
119
+ - **No staff-actor audit trail exists for these writes.** `Logs_Compass.Record.userId` is a **tenant**
120
+ `Users.id`, so it cannot record a `Client_True` staff member — it is not usable as the audit log
121
+ for this tool, and `Logs_Compass.Record` is the client-facing activity log (3.6M rows) besides.
122
+ See
123
+ [Record Change Audit Log](../../../2.0/apps/_underscore/features/record-change-audit-log.md).
124
+ Auditing internal-tool actions remains an open item.
125
+ - **Detecting a no-op UPDATE needs `App_Database::affectedRows()`** — `App_Database::query()` returns
126
+ the `App_Query` execute result, not a row count. Details in
127
+ [MVC data access patterns](./mvc-data-access-patterns.md).
128
+
129
+ ## Change history
130
+
131
+ - 2026-09-04 — Initial: built the two-page Compass user/persona admin (PR tools#13, unmerged) — the
132
+ first `tools` pages that write to a client tenant DB, gated to `Development Team` only, with a
133
+ US/Canada switcher and new `[database_compass]` / `[database_compasscanada]` ini sections reusing
134
+ the existing `[database_true]` account. Recorded Jeff Cardinal's scope reset (no schema changes;
135
+ `dbchanges2#478` and `worker2#160` closed unmerged) and its consequence — **activation is temporary
136
+ because the `c_isKeepActive` flag was dropped**, while deactivation is durable. Logged the accepted
137
+ credential-reuse risk, the 4-day session persona staleness, the missing staff-actor audit trail, and
138
+ that every user search is a full scan. (ajean)
@@ -6,8 +6,8 @@ project: Tools
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-04
10
- owners: [jcardinal, bala]
9
+ updated: 2026-09-04
10
+ owners: [jcardinal, bala, ajean]
11
11
  files:
12
12
  - tools/_/app/nav.php
13
13
  - tools/mvc/get.php
@@ -15,6 +15,7 @@ files:
15
15
  - library/app/database.php
16
16
  related:
17
17
  - ./persona-gated-navigation.md
18
+ - ./compass-user-persona-admin.md
18
19
  - ./saml-sso-auth.md
19
20
  - ../architecture.md
20
21
  - ../../library/features/mvc-page-pattern-and-app-skeleton.md
@@ -57,6 +58,31 @@ add one expecting the framework to validate it.
57
58
  2.0 DB read-only through its own connection (e.g. `db_true → Client_True`); adding
58
59
  `db_team → Team` is the same pattern.
59
60
 
61
+ ### Detecting a no-op UPDATE — `App_Database::affectedRows()`
62
+ `App_Database::query()` returns the `App_Query` execute result, **not** a row count, so it cannot
63
+ tell you whether an `UPDATE` actually changed anything. Use
64
+ `App_Database::affectedRows($linkRegistryString)` — pass the same alias you queried with. This is
65
+ how a page distinguishes "saved" from "the value was already that".
66
+
67
+ ### DB credentials come straight from the ini — there is no secret store
68
+ `App_Database::registerDatabaseConnect()` (`library/app/database.php:125-128`) reads
69
+ **hostname / username / password / dbname directly from the config ini**. Only
70
+ `database_reader_endpoint` goes through `App_Cloud::getParameter()`. So a 1.0 app **cannot** move a
71
+ DB credential into a secret store without changing shared `library` core that every 1.0 app boots
72
+ through. Plan around it: `config.*.ini` is where DB credentials live, and it is committed (see the
73
+ config/secrets note in [the architecture doc](../architecture.md)).
74
+
75
+ ### Input filtering — `App_Page::getVarIfSet()` allows printable ASCII
76
+ `getVarIfSet()` runs `App_String::filterCharactersValidCharactersOnly()`, which permits **ASCII
77
+ 32-126**. Emails, usernames and personnel numbers survive intact, so it is safe for those search
78
+ inputs. It is a character filter, not an escaper — still escape/cast before the value reaches SQL.
79
+
80
+ ### Production MySQL runs `US/Central`
81
+ The prod MySQL server timezone is **`US/Central`**, matching the 1.0 framework's `America/Chicago`
82
+ and worker2's Central-time cron scheduling (verified 2026-09-02). So MySQL `CURDATE()` and PHP
83
+ `date('Y-m-d')` agree — there is **no** day-boundary divergence to design around when the same date
84
+ check exists in both SQL and PHP.
85
+
60
86
  ### Gotcha — by-reference row helpers
61
87
  `buildArrayOfRows`, `fetchOne`, and `fetchRow` take `$res` **by reference**. Assign the
62
88
  `query()` result to a **variable first**, then pass it — passing the call result inline emits
@@ -142,6 +168,14 @@ set_error_handler(fn(): bool => true); // swallow non-fatal notices/deprecations
142
168
  `App_Talos_Estimator` → `_/app/talos/estimator.php`.
143
169
 
144
170
  ## Change history
171
+ - 2026-09-04 — Added four `App_Database` / input facts found building the Compass admin pages:
172
+ `affectedRows($alias)` is how you detect a no-op `UPDATE` (`query()` returns the `App_Query`
173
+ execute result, not a row count); `registerDatabaseConnect()` reads DB credentials **directly from
174
+ the config ini** (only `database_reader_endpoint` uses `App_Cloud::getParameter()`), so there is no
175
+ secret-store option without changing shared 1.0 core; `App_Page::getVarIfSet()` filters to
176
+ printable ASCII 32-126, so emails/usernames/personnel numbers pass through intact; and production
177
+ MySQL runs timezone **`US/Central`**, matching PHP's `America/Chicago`, so `CURDATE()` and
178
+ `date('Y-m-d')` agree. (ajean)
145
179
  - 2026-08-04 — Added two gotchas found rebuilding the Talos Pricing UI: (1) `App_Database::fetchOne()` is **not** an existence test — it `data_seek`s and returns `$row[$col]` with no empty check, so zero rows dereferences null → uncaught `ErrorException` → bare 500; a **library-wide** footgun affecting every 1.0 app (use `COUNT(*)` or `buildArrayOfRows` + `empty()`). (2) Tools runs **PHP 8.5**, where "using null as an array offset" is deprecated even inside `isset()` and the framework escalates deprecations to fatals — cast to `(string)` before indexing; PHP 8.2 local dev does not reproduce it. (jcardinal)
146
180
  - 2026-08-03 — Added the error-handler escalation gotcha: `App_Error::handleError` promotes ANY PHP
147
181
  notice/deprecation to a thrown `ErrorException` (ignoring `error_reporting`) and, without Sentry,
@@ -6,8 +6,8 @@ project: Tools
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-30
10
- owners: [jcardinal]
9
+ updated: 2026-09-04
10
+ owners: [jcardinal, ajean]
11
11
  files:
12
12
  - tools/_/app/auth.php
13
13
  - tools/mvc/sso/initiate/get.php
@@ -20,6 +20,7 @@ files:
20
20
  - tools/config.local.ini
21
21
  related:
22
22
  - ../architecture.md
23
+ - ./compass-user-persona-admin.md
23
24
  - ../../library/features/app-sso-initiation.md
24
25
  - ../../../2.0/apps/saml/features/downstream-integration-contract.md
25
26
  - ../../../clients/true/features/users-personas-data-model.md
@@ -115,6 +116,17 @@ removed.
115
116
 
116
117
  ## Gotchas / known issues
117
118
 
119
+ - **⚠ Personas are snapshotted at login and sessions last 4 days — revoking a persona does not
120
+ revoke access.** `App_Auth` caches the user's persona **names** into `$_SESSION` at sign-in and
121
+ `requireAuth()` / `App_Nav` both read that cache, never the live `Users_Personas` rows. So removing
122
+ someone's persona leaves them able to use every page it gated for up to **4 days**, until their
123
+ session expires. This matters most for pages that write to a client tenant — see the [Compass
124
+ admin pages](./compass-user-persona-admin.md). To cut access off now, kill the session, do not just
125
+ revoke the persona.
126
+ - **The Tools session cookie has no `Secure` flag.** `App_Framework_Tools` sets `useSessions = false`,
127
+ so the parent `library` framework's `cookie_secure` setup never runs; Tools sets the cookie itself
128
+ with HttpOnly + SameSite=Lax only. Open item — do not assume the cookie is HTTPS-only.
129
+
118
130
  - **Consumer route runs mid-render → headers already sent.** The 1.0 framework runs `mvc/sso/get.php`
119
131
  **inside** the page render (`frameworkindex → body → loadFile`) after `common/header.php` has
120
132
  emitted output, so `session_regenerate_id(true)` fatals ("cannot be regenerated after headers
@@ -180,6 +192,11 @@ remains (already noted in tools knowledge) — **flag for rotation.** Document *
180
192
  never the values.
181
193
 
182
194
  ## Change history
195
+ - 2026-09-04 — Recorded two standing security gaps found while adding tenant-writing pages: personas
196
+ are **snapshotted into `$_SESSION` at login** and sessions last **4 days**, so revoking a persona
197
+ does not revoke page access until the session dies (kill the session instead); and the Tools
198
+ session cookie carries **no `Secure` flag**, because `App_Framework_Tools` sets `useSessions =
199
+ false` and the parent framework's `cookie_secure` setup therefore never runs. (ajean)
183
200
  - 2026-06-30 — Fixed SSO "Sign in failed" (`/login?error=1`) regression and **corrected the earlier wrong "config tokens are removable" note**. The prior fix moved only the consumer/decrypt leg to `Core.Parameters` and advised the `[saml] api_secret_access_token` was removable — but the **initiate** leg (`mvc/sso/initiate/get.php`) still read it from config, so removing it made `App_Sso::initiate()` throw on an empty token (`library/app/sso.php:64-65`) → `/login?error=1`. Fix: **both** legs now source the rotating secret from `Core.Parameters` — added public `App_Auth::currentApiSecret()` (reuses cached `handoffKeys()`), and `sso/initiate/get.php` passes it instead of the config value. Documented the non-empty-token requirement, the `/login?error=1` (initiate) vs "Authentication failed" (consumer) leg distinction, the `true_client_uuid`-stays-in-config rule, and the `db_toga2core`-missing→500 diagnostic. `php -l` clean on both files. (jcardinal)
184
201
  - 2026-06-30 — Fixed prod SSO login failure: handoff decrypt keys now sourced from `Core.Parameters` (rotated `API_SECRET_ACCESS_TOKEN`/`_PREVIOUS`) via new `App_Auth::handoffKeys()` over `db_toga2core` (per-request cached, current→previous fallback), instead of the stale committed `config [saml]` tokens — the stale key was the root cause. Added `[database_toga2core]` connection requirement to every config and documented the AES-256-CBC crypto format. Fixed `tools_ssoFail()` 500→intended 401: `http_response_code(401)` fataled on headers-already-sent (preloader flushes before mvc page runs); guarded with `!headers_sent()`. (jcardinal)
185
202
  - 2026-06-26 — Wired up real SSO initiation via the new 1.0 `App_Sso` library class (`/sso/initiate`), replacing the `initiation_url` placeholder; registered the fixed-uuid Core.Domains return row (dbchanges2 `2026-06-26a`); fixed the `session_regenerate_id` headers-already-sent fatal (guarded with `!headers_sent()`) and documented the mid-render failure-path limitation; collapsed home → `/login` to one-step sign-in; noted prod secrets live in `config.production.ini [saml]` (jcardinal)
@@ -6,7 +6,7 @@
6
6
  | [Canon received-PO → NetSuite sales order sync](features/canon-received-po-netsuite-order-sync.md) | A 1.0 `worker` cron that reads Canon customer purchase orders out of the **Vision** databases and creates the matching **sales order in NetSuite**. | worker/crons/sync/sync_received_po.php, library/netsuitetoolkit/NSPHPClient.php |
7
7
  | [Compass MA Sales Order Exception Report](features/compass-ma-sales-order-exception-report.md) | A worker cron that emails operations the "Compass Refresh Exception Report" — Compass `MA%` sales orders whose corresponding Office Depot (ODP) sales order has | worker/crons/toga2/compass/workflow/7_generate_ma_sales_order_exception_report.php, worker/schedules/cron.worker.sync.json |
8
8
  | [Compass Manager Approval Reminder Emails (1.0 worker crons)](features/compass-manager-approval-reminder-emails.md) | Two 1.0 worker crons nag approvers about sales orders still waiting on a decision — one per Compass tenant. | worker/crons/toga2/compass/compass_email_reminders.php, worker/crons/toga2/compasscanada/compass_email_reminders.php, worker/schedules/cron.worker.sync.json, worker1.5/crons/toga2/compass/compass_email_reminders.php, worker1.5/schedules/cron.worker.json |
9
- | [Compass Partial In-Transit & Delivered Emails (per package)](features/compass-partial-in-transit-delivered-emails.md) | Compass USA and Compass Canada send a **per-package** in-transit email (and a matching delivered email) instead of one email listing the whole order. | worker/crons/toga2/compass/update_salesorder_status_from_odp.php, worker/crons/toga2/compass/send_delivered_email.php, worker/crons/toga2/compasscanada/update_salesorder_status_from_odp.php, worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php, worker/crons/toga2/compasscanada/send_delivered_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_delivered_email.php, library/app/client/compasscanada.php |
9
+ | [Compass Partial In-Transit & Delivered Emails (per package)](features/compass-partial-in-transit-delivered-emails.md) | Compass USA and Compass Canada send a **per-package** in-transit email (and a matching delivered email) instead of one email listing the whole order. | worker/crons/toga2/compass/update_salesorder_status_from_odp.php, worker/crons/toga2/compass/send_delivered_email.php, worker/crons/toga2/compasscanada/update_salesorder_status_from_g&t.php, worker/crons/toga2/compasscanada/send_delivered_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_delivered_email.php, library/app/client/compasscanada.php, worker/schedules/cron.worker.sync.json, worker2/Worker/Monitor/Compass.php, dbchanges2/Client_Compass/2026-09-03 - compass_intransit_email_backlog_backfill.sql |
10
10
  | [Elite TOGA 2.0 → TOGaDeskSupport Standalone Attachment Sync](features/elite-togadesk-attachment-sync.md) | `sync_togadesk_elite_attachments.php` is a standalone cron (every 5 minutes) that syncs file attachments from TOGA 2.0 into TOGaDeskSupport for Elite. | worker/crons/toga2/elite/sync_togadesk_elite_attachments.php, worker/crons/toga2/elite/test_sync_togadesk_elite_attachments.php |
11
11
  | [Forecast2 ↔ NetSuite Reconciliation & Trueup Tooling](features/forecast2-netsuite-reconciliation.md) | CLI tools to **audit** and **repair** drift between the production `Forecast` DB (core2) and NetSuite. | test/@dave/checker.php, worker2/Component/Forecast/SaleImport/SaleImport.php, test/@dave/looper.php, test/@dave/reconcile_netsuite_totals.php, test/@dave/fixer.php, tools/bin/forecast/fixer.php, tools/bin/forecast/checker.php, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.php, test/@dave/reconcile_drift_2023plus.php, test/@dave/probe_invoice_gap_2026.php, test/@dave/probe_creditmemo_gap_detail.php, test/@dave/trueup_open_orders.php, test/@dave/loop_trueup_open_orders.php, test/@dave/trueup_opportunities.php, test/@dave/probe_sales_gap_direct.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_profit_invoices.php, test/@dave/probe_profit_gap.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_open_orders.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/schedules/cron.worker.infrastructure.json |
12
12
  | [Forecast2 Supporting-Records Nightly Import (1.0 cron) — Customers & ConsolidatedCustomers](features/forecast2-supporting-records-import.md) | The nightly **1.0** pull that keeps the Forecast2 lookup/dimension tables (`Forecast.Accounts`, `Classifications`, `Customers`, `ConsolidatedCustomers`, `Employ | worker/crons/toga2/forecast2/import_supporting_records.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker/schedules/cron.worker.infrastructure.json, worker/.ebextensions/009_setup_phpini.config, worker/crons/sync/netsuite/netsuite_customers.php, library/app/api/netsuite/rest.php, library/app/model/forecast2/customer.php, library/app/model/forecast2/consolidatedcustomer.php |
@@ -6,18 +6,23 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-26
9
+ updated: 2026-09-04
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - worker/crons/toga2/compass/update_salesorder_status_from_odp.php
13
13
  - worker/crons/toga2/compass/send_delivered_email.php
14
- - worker/crons/toga2/compasscanada/update_salesorder_status_from_odp.php
15
- - worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php
14
+ - worker/crons/toga2/compasscanada/update_salesorder_status_from_g&t.php
16
15
  - worker/crons/toga2/compasscanada/send_delivered_email.php
17
16
  - worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php
18
17
  - worker/crons/toga2/compasscanada/workflow/test_partial_delivered_email.php
19
18
  - library/app/client/compasscanada.php
19
+ - worker/schedules/cron.worker.sync.json
20
+ - worker2/Worker/Monitor/Compass.php
21
+ - dbchanges2/Client_Compass/2026-09-03 - compass_intransit_email_backlog_backfill.sql
20
22
  related:
23
+ - ../../../clients/compass-usa/features/contact-email-resolution.md
24
+ - ../../../2.0/apps/worker2/features/tracking-status-refresh.md
25
+ - ../../../2.0/apps/worker2/features/oneuptime-worker2-monitoring.md
21
26
  - ../../../clients/compass-canada/features/french-order-email-localization.md
22
27
  - ../../../clients/compass-usa/features/order-fulfillment-status-per-line.md
23
28
  - ../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
@@ -34,18 +39,28 @@ and earlier packages). The dynamic HTML is injected into a stored `EmailTemplate
34
39
 
35
40
  ## Key files / entry points
36
41
 
37
- - `worker/crons/toga2/compass/update_salesorder_status_from_odp.php` — Compass USA prod cron.
38
- - `worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php`
39
- — Compass Canada prod cron.
42
+ - `worker/crons/toga2/compass/update_salesorder_status_from_odp.php` — Compass USA prod cron,
43
+ registered in `worker/schedules/cron.worker.sync.json` as **"Update Compass SalesOrder Status
44
+ From ODP CXML"**, schedule **`0 */4 * * *`** — it fires at 00/04/08/12/16/20 **US/Central**
45
+ (the prod DB timezone). Every threshold below (batch cap, staleness, monitor alarms) is
46
+ calibrated to that 4-hour cadence.
47
+ - `worker/crons/toga2/compasscanada/update_salesorder_status_from_g&t.php` — Compass Canada
48
+ prod cron, registered as "Update CompassCanada SalesOrder Status From G&T CXML".
49
+ **⚠ This filename contains `&` and therefore cannot run — see the gotcha below.** It was
50
+ renamed from `update_salesorder_status_from_odp.php` on 2026-09-03; the duplicate
51
+ `workflow/3_update_salesorder_status_from_grand_and_toy.php` was **deleted** in the same pass and
52
+ its stale `schedules/cron.worker.sync.json` entry removed.
40
53
  - Shared helper set in each cron: `getOrderFulfillmentData`, `buildItemRowsHtml`,
41
54
  `buildSectionHeaderHtml`, `buildPackageBlockHtml`, `buildTrackingUrl`, `buildOrderItemsHtml`.
42
55
 
43
56
  ## How it works
44
57
 
45
58
  1. **Driving query** finds shipped sales orders and their tracking numbers (`computedStatusSlug
46
- = 'shipped'`, `c_dtInTransitEmailSent IS NULL`, date cutoff) via the ASN chain
47
- (`AdvanceShippingNoticeItemUnits_TrackingNumbers`), one row per `TrackingNumbers.number`.
48
- 2. Per tracking number: skip if the carrier API already reports delivered.
59
+ = 'shipped'`, `c_dtInTransitEmailSent IS NULL`, rolling date window) via the ASN chain
60
+ (`AdvanceShippingNoticeItemUnits_TrackingNumbers`), one row per `TrackingNumbers.number`,
61
+ **newest first and capped** — see *Draining the candidate set* below.
62
+ 2. Per tracking number: skip if the shipment is already finished — checked first against the
63
+ stored `TrackingNumbers.status` (free), and only then with a live carrier call.
49
64
  3. **`getOrderFulfillmentData`** returns the order's packages (grouped by tracking number,
50
65
  ordered by `dtCreated`) and ordered items, sourced from the **`ItemFulfillmentItems`** line
51
66
  bridge (`ItemFulfillmentItems` → `ItemFulfillments` (scoped to the buyer SO) →
@@ -58,6 +73,73 @@ and earlier packages). The dynamic HTML is injected into a stored `EmailTemplate
58
73
  `App_Api_Toga2::send(..., 'POST', '/email-templates/sendEmail', $payload, [], true)`.
59
74
  6. On a successful (non-throwing) send, set `c_dtInTransitEmailSent = NOW()`.
60
75
 
76
+ ### Draining the candidate set: two-pass, newest-first, bounded
77
+
78
+ **This is the load-bearing part of the cron. Getting it wrong took Compass USA in-transit emails to
79
+ near zero for a week** (2026-08-27 → 2026-09-03: 130–190 a day before, then 7 / 1 / 6 / 0 / 1).
80
+ Customers were not told their order had shipped.
81
+
82
+ The original shape was: select **every** unstamped `TrackingNumbers` row since a hardcoded
83
+ `'2026-06-15'`, then call UPS/FedEx **live, one row at a time**. When a shipment turned out to be
84
+ already delivered the loop `continue`d **without stamping `c_dtInTransitEmailSent`** — so the same
85
+ row came back on the next run, forever. The candidate set grew to **2,379** and the run hit its
86
+ `App_Framework::cronInitialization(3600)` one-hour limit while still inside the June rows. It never
87
+ reached the day's new ASNs. Nothing errored; the cron just ran out of time on every tick.
88
+
89
+ Four changes fix it, and they are the pattern to copy for any *"poll an external API per row, then
90
+ mark the row done"* cron:
91
+
92
+ 1. **Two passes.** Pass 1 walks the candidates and **stamps** every already-finished row so it
93
+ retires from the candidate set permanently. Pass 2 sends. A row can never again be examined
94
+ forever without being retired.
95
+ 2. **Newest first, hard cap.** `ORDER BY MAX(TrackingNumbers.dtCreated) DESC` +
96
+ `LIMIT IN_TRANSIT_EMAIL_BATCH_SIZE` (**400**). Today's shipments are served before any backlog,
97
+ so a backlog can delay old mail but can never starve new mail.
98
+ 3. **Rolling window, not a fixed date.** `IN_TRANSIT_EMAIL_LOOKBACK_DAYS = 14` →
99
+ `dtCreated >= DATE_SUB(NOW(), INTERVAL 14 DAY)`. A hardcoded cutoff date guarantees an
100
+ ever-growing scan; a rolling window is self-limiting.
101
+ 4. **Trust the stored status before paying for a carrier call.** `isTrackingStatusFinal(?string)`
102
+ short-circuits when `TrackingNumbers.status` is already `DELIVERED` or `RETURNED_TO_SENDER`
103
+ (`TRACKING_FINAL_STATUSES`). worker2's
104
+ [centralized tracking-status refresh](../../../2.0/apps/worker2/features/tracking-status-refresh.md)
105
+ keeps that column current for every client, so this 1.0 cron should not re-ask the carrier about
106
+ a finished shipment.
107
+
108
+ ### Resolving the recipient: a `Users` row is NOT required (USA)
109
+
110
+ The To address originally came only from `Users.email`, via `INNER JOIN Users ON Users.contactId =
111
+ …`. **EDI-created MR orders never get a `Users` row**, so those orders were silently absent from
112
+ the driving query — 12 MR orders with shipped parcels were never emailed and never even showed up
113
+ as a backlog.
114
+
115
+ The USA cron now resolves the address in this precedence:
116
+
117
+ ```
118
+ LEFT OUTER JOIN Users
119
+ ON Users.contactId = ShippedSalesOrders.contactId
120
+ AND Users.email IS NOT NULL AND Users.email <> ''
121
+ INNER JOIN Contacts
122
+ ON Contacts.id = ShippedSalesOrders.contactId
123
+ LEFT OUTER JOIN ContactEmailAddresses
124
+ ON ContactEmailAddresses.id = Contacts.primaryContactEmailAddressId
125
+ AND ContactEmailAddresses.contactId = Contacts.id # ownership guard
126
+ …
127
+ IFNULL(Users.email, ContactEmailAddresses.emailAddress) AS contactEmail
128
+ ```
129
+
130
+ The same `IFNULL` resolves the display name, and the `WHERE` requires the resolved address to be
131
+ non-empty.
132
+
133
+ **The `ContactEmailAddresses.contactId = Contacts.id` predicate is not optional.** About 14% of
134
+ Compass contacts point at an email row owned by a *different* contact, so without it the cron mails
135
+ the wrong person — see
136
+ [Compass contact email resolution](../../../clients/compass-usa/features/contact-email-resolution.md).
137
+
138
+ **Regression-checked, not assumed.** Over 60 days and **4,419 SA tracking rows** the old and new
139
+ queries both select **4,416**: 0 newly included, 0 lost, resolved address and name identical on all
140
+ 4,416. The `Users` address always wins because it is first in the `IFNULL`, so the change is
141
+ strictly additive — it only reaches orders the old query could not see at all.
142
+
61
143
  ### Tracking-URL building (`buildTrackingUrl`)
62
144
 
63
145
  The "Track Shipment" button and each per-package link resolve in this precedence:
@@ -100,7 +182,22 @@ the tracking number as emailed (it retries next run).
100
182
 
101
183
  ## Client variations
102
184
 
103
- - **Compass USA** — English only; `update_salesorder_status_from_odp.php`.
185
+ - **Compass USA** — English only; `update_salesorder_status_from_odp.php`. Has the full
186
+ 2026-09-03 hardening: two-pass drain, 400-row cap, 14-day window, `isTrackingStatusFinal()`,
187
+ **and** the `Users` → `Contacts`/`ContactEmailAddresses` recipient fallback with the ownership
188
+ guard.
189
+ - **⚠ Canada is only PARTLY at parity (verified 2026-09-04).**
190
+ `compasscanada/update_salesorder_status_from_g&t.php` has the batching changes
191
+ (`IN_TRANSIT_EMAIL_BATCH_SIZE = 400`, `IN_TRANSIT_EMAIL_LOOKBACK_DAYS = 14`,
192
+ `ORDER BY MAX(TrackingNumbers.dtCreated) DESC`, `isTrackingStatusFinal()`) but **still resolves
193
+ the recipient with `INNER JOIN Users … Users.email AS contactEmail`** — no `Contacts` /
194
+ `ContactEmailAddresses` fallback and no ownership guard. Canada therefore still cannot email a
195
+ contact that has no `Users` row. Port the recipient block if Canada ever gets EDI-created MR
196
+ orders.
197
+ - **Canada keeps its own retry behaviour on a carrier throw.** It leaves the row **unstamped** when
198
+ the carrier lookup throws, so the next run retries it; only rows confirmed *final* are stamped.
199
+ That is deliberate and must survive any future parity pass — stamping on a throw would silently
200
+ drop the email.
104
201
  - **Compass Canada** — EN/FR by `UserGlobalSettings.settingId = 2` (FR when the value starts
105
202
  with `fr`); French section labels and a French (HTML-entity) status message; uses
106
203
  `htmlentities` in the section header builder and a 7-param French-aware `buildPackageBlockHtml`.
@@ -113,6 +210,33 @@ the tracking number as emailed (it retries next run).
113
210
 
114
211
  ## Gotchas / known issues
115
212
 
213
+ - **⚠ A cron filename containing `&` cannot run at all.** `worker/ebs/cron.worker.php:41` builds
214
+ the crontab command by plain unquoted concatenation
215
+ (`'php /var/www/html/crons/' . $cron->cron`), so cron reads `&` as *"background this and start a
216
+ new command"*: `update_salesorder_status_from_g&t.php` executes as
217
+ `…/update_salesorder_status_from_g` plus a stray `t.php`, and the real script never runs —
218
+ silently, on every tick. **The Canada cron is in this state as of 2026-09-04 and must be renamed
219
+ (e.g. `update_salesorder_status_from_gt.php`) with `schedules/cron.worker.sync.json` repointed.**
220
+ Never put `&` (or any shell metacharacter) in a cron filename. See
221
+ [tracing a 1.0 worker cron run](../workflows/tracing-a-worker-cron-run-in-production.md).
222
+ - **⚠ Invoice tracking numbers are NOT in scope and must never be counted as in-transit
223
+ backlog.** `POST /v2/invoice-tracking-numbers` creates `TrackingNumbers` rows bridged through the
224
+ **`InvoiceTrackingNumbers`** table — never the ASN chain. On 2026-09-03 alone, 98 were created
225
+ (11:15–11:40) and **zero** of them touch `AdvanceShippingNoticeItemUnits_TrackingNumbers`.
226
+ Anything that walks the ASN unit bridge (this cron, and the OneUptime queue monitor) correctly
227
+ ignores them. Their shape is distinctive: **`shippingCarrierId` set, `shippingMethodId` NULL**.
228
+ A naive `TrackingNumbers WHERE c_dtInTransitEmailSent IS NULL` count will read these as a backlog
229
+ that never drains.
230
+ - **Clear an in-transit backlog by STAMPING, not by emailing — and refresh statuses first.**
231
+ Decided 2026-09-03 while draining the outage backlog
232
+ (`dbchanges2/Client_Compass/2026-09-03 - compass_intransit_email_backlog_backfill.sql`):
233
+ `c_dtInTransitEmailSent` was stamped on **64,132** rows older than the 14-day window and **421**
234
+ in-window rows already on a final status, leaving **68** genuinely still moving. Sending an
235
+ "in transit" email for a June order that arrived two months ago confuses the customer, so old
236
+ rows retire silently. **Ordering matters:** let worker2 `Sync/TrackingNumbers` refresh any
237
+ recently-unparked rows **before** running the backfill, otherwise a row with a stale status gets
238
+ a wrong in-transit email. (A parked row holds a stale status indefinitely — see
239
+ [tracking-status refresh](../../../2.0/apps/worker2/features/tracking-status-refresh.md).)
116
240
  - **Canada now uses the `ItemFulfillmentItems` line bridge, not the ASN chain**, for
117
241
  `getOrderFulfillmentData` — same query as Compass USA. Canada was originally on the ASN chain
118
242
  because it had **zero `ItemFulfillmentItems`**; if that line-bridge data is not populated for a
@@ -138,7 +262,7 @@ the tracking number as emailed (it retries next run).
138
262
  language even though the mail goes to the **manager**. Fixed 2026-08-03.
139
263
  - **⚠ Each cron keeps its OWN duplicated copy of the 2.0 `_status` CASE — a standing divergence
140
264
  risk.** `compass/update_salesorder_status_from_odp.php`,
141
- `compasscanada/update_salesorder_status_from_odp.php` and
265
+ `compasscanada/update_salesorder_status_from_g&t.php` and
142
266
  `compass/workflow/test_partial_in_transit_email.php` each re-implement the model's status SQL as
143
267
  `computedStatusSlug`. Today that is safe by luck of scope: the copies only ever produce canceled /
144
268
  pendingApprovalUnknown / pendingFulfillment / shipped and they filter on
@@ -161,6 +285,27 @@ the tracking number as emailed (it retries next run).
161
285
 
162
286
  ## Change history
163
287
 
288
+ - 2026-09-04 — **Fixed the Compass USA in-transit email outage (2026-08-27 → 2026-09-03, 130–190
289
+ emails/day → near zero).** Root cause: already-delivered rows were `continue`d without stamping
290
+ `c_dtInTransitEmailSent`, so the candidate set grew to 2,379 and every run burned its
291
+ `cronInitialization(3600)` hour inside rows dating back to a hardcoded `'2026-06-15'`, never
292
+ reaching new ASNs. Fix: two-pass drain (stamp finished rows, then send), newest-first
293
+ `ORDER BY MAX(dtCreated) DESC` with `IN_TRANSIT_EMAIL_BATCH_SIZE = 400`, rolling
294
+ `IN_TRANSIT_EMAIL_LOOKBACK_DAYS = 14` replacing the fixed date, and `isTrackingStatusFinal()`
295
+ short-circuiting the carrier call on `DELIVERED`/`RETURNED_TO_SENDER` using the status worker2
296
+ `Sync/TrackingNumbers` already maintains. Also **widened the recipient**: `LEFT OUTER JOIN Users`
297
+ + `Contacts`/`ContactEmailAddresses` with an ownership guard and
298
+ `IFNULL(Users.email, ContactEmailAddresses.emailAddress)`, which recovered 12 EDI-created MR
299
+ orders that have no `Users` row (regression-checked: 4,416 of 4,419 SA rows selected by both old
300
+ and new queries, 0 gained, 0 lost, identical resolved address/name). Backlog cleared by
301
+ **stamping** 64,132 out-of-window + 421 final-status rows rather than emailing them. Recorded
302
+ that **invoice tracking numbers are not in scope** (bridged via `InvoiceTrackingNumbers`, never
303
+ the ASN chain) and that a **`&` in a cron filename stops the cron dead**. Canada got the batching
304
+ changes only (renamed to `update_salesorder_status_from_g&t.php`; duplicate
305
+ `workflow/3_update_salesorder_status_from_grand_and_toy.php` deleted); its recipient block is
306
+ **still `INNER JOIN Users`**. New OneUptime monitor `Monitor/Compass/InTransitEmailQueue` now
307
+ watches this queue — see
308
+ [OneUptime push-metric monitors](../../../2.0/apps/worker2/features/oneuptime-worker2-monitoring.md). (bala)
164
309
  - 2026-08-26 — Flagged the **duplicated `_status` CASE** in the three crons as a standing divergence
165
310
  risk after the 2.0 Compass fulfillment rule was rewritten. Verified the crons are unaffected: their
166
311
  copies only emit canceled / pendingApprovalUnknown / pendingFulfillment / shipped and filter on
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-09-01
9
+ updated: 2026-09-04
10
10
  owners: ["bala", "kyalamarthi"]
11
11
  files:
12
12
  - worker/ebs/cron.worker.php
@@ -170,7 +170,28 @@ to **2.0 only**. The 1.0 standard wins in `library/`, `worker/` and every other
170
170
  `library/app/systemmonitor/500error.php` uses `str_contains()`; that file is not a version
171
171
  signal, it is a latent bug.
172
172
 
173
+ ## Deploy-time trap: a `&` (or any shell metacharacter) in the cron FILENAME silently kills it
174
+ `ebs/cron.worker.php:41` builds each crontab line by plain string concatenation
175
+ (`'php /var/www/html/crons/' . $cron->cron`) with **no quoting and no escaping**. Cron then parses
176
+ that line as a shell command, so a `&` in the filename is read as *"run everything before this in
177
+ the background and start a new command."*
178
+
179
+ Worked example (found 2026-09-03): a developer renamed the Compass Canada status cron to
180
+ `crons/toga2/compasscanada/update_salesorder_status_from_g&t.php` and repointed
181
+ `schedules/cron.worker.sync.json` at it. The crontab line becomes two commands —
182
+ `php .../update_salesorder_status_from_g` (a file that does not exist) and a stray `t.php` — so the
183
+ real script **never executes**, on every tick, with no error anywhere. It looks exactly like
184
+ "the cron never fired".
185
+
186
+ **Rule: a cron filename must be plain `[a-z0-9_]` plus `.php`.** No `&`, no spaces, no `;`, `|`,
187
+ `$`, `(`, `)`, `*`, `?`, or quotes. The *display name* in `schedules/*.json` is free text and may
188
+ contain anything; only the `cron` path is shell-parsed.
189
+
173
190
  ## Change history
191
+ - 2026-09-04 — Added the **`&`-in-a-cron-filename trap**: `ebs/cron.worker.php:41` concatenates the
192
+ path into the crontab line unquoted, so a shell metacharacter splits the command and the script
193
+ never runs while leaving no trace. Found via the renamed Compass Canada
194
+ `update_salesorder_status_from_g&t.php`. (bala)
174
195
  - 2026-09-01 — Added **Step 6: check the email log, not the inbox** — a 1.0 cron's sends land in
175
196
  the **legacy** env's `Logs.Emails` (schema `Logs`, table `Emails`), never prod `Logs` or
176
197
  `Logs_<Client>`; gave the verification query, and noted that a missing row usually means the job
@@ -19,6 +19,7 @@
19
19
  | [_Config group access — the two-argument "optional" form does NOT rescue a missing GROUP](features/config-group-access.md) | `_Config::<group>('<property>')` reads a value out of the active `Config/<ENVIRONMENT>.ini`, and **it throws when the requested group is absent from the ini.** | _underscore/Config.php, _underscore/Component/Api/Paypal/Paypal.php, _underscore/Worker.php, api2/Config/beta.ini, api2/Config/sandbox-dev.ini |
20
20
  | [Core.Domains — the app host registry (and why you cannot derive a host)](features/core-domains-app-host-registry.md) | `Core.Domains` is the **authoritative registry of which host serves which client's app in which environment**. | _underscore/Model/Core/Domain.php, api2/Component/Api/CrossClient/CrossClient.php, api2/Component/Api/V2/V2.php, worker/config.beta.ini |
21
21
  | [Re-pointing a DB alias mid-request (_Database::register park/restore)](features/database-alias-repointing.md) | `_Database` keys **all live per-database runtime state by the connection ALIAS** (`Client` / `_underscore::DB_CLIENT`, `ClientLogs`, `Archive`), **not** by the | _underscore/Database.php, _underscore/Query.php, api2/Component/Api/V2/V2.php, api2/Component/Api/CrossClient/CrossClient.php |
22
+ | [Effective persona resolution (_Model_Client_User::_personaIds) — the three sources and the sector hop](features/effective-persona-resolution.md) | A user's **effective** persona set is **not** `Users_Personas`. | _underscore/Model/Client/User.php |
22
23
  | [2.0 Email Send Pipeline (queue + Send worker)](features/email-send-pipeline.md) | In 2.0, `_Email::send()` **does not transmit** — it queues the message. | _underscore/Email.php, _underscore/String.php, worker2/Worker/Infrastructure/Email/Send.php, _underscore/Model/Client/Logs/Email.php, _underscore/Model/Client/Logs/EmailAttachment.php |
23
24
  | [Client Email Template Sending](features/email-template-sending.md) | `_Model_Client_EmailTemplate` sends a stored, client-defined email template by UUID. | _underscore/Model/Client/EmailTemplate.php, _underscore/Model/Quad/ApprovalDecision.php, _underscore/Model/Quad/SalesOrder.php, _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php, _underscore/Email.php |
24
25
  | [Error Reporting — Issue/Event Capture, Fingerprinting & Aggregation](features/error-reporting-issue-event.md) | Platform-wide error reporting for TOGA 2.0, built on an **Issue / Event** aggregation model in the **shared Core Logs DB**. | _underscore/Error.php, _underscore/Query.php, _underscore/Database.php, _underscore/Exception/Business.php, _underscore/Exception/Database.php, _underscore/Test/DatabaseFingerprint.php, api2/Controller/Index.php, worker2/Controller/Index.php, _underscore/Model/Core/Logs/Issue.php, _underscore/Model/Core/Logs/Event.php, _underscore/Model/Core/Logs/IssueFingerprint.php, _underscore/Model/Core/Logs/IssueClickupTask.php, _underscore/Model/Core/Logs/IssueEmailAddress.php, _underscore/Model/Core/Logs/IssueAreaOwner.php, worker2/Worker/Infrastructure/Errors.php, tools/mvc/errors/issue/get.php, tools/mvc/errors/post.php, dbchanges2/Logs/2026-07-30a - Error reporting Issues and Events.sql, dbchanges2/Logs/2026-08-01a - Rename tables to singular.sql, dbchanges2/Logs_Client/2026-08-01a - Drop Error table.sql, dbchanges2/Core/2026-07-30a - Error escalation cron job.sql |