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.
- package/knowledge/1.0/apps/tools/INDEX.md +1 -0
- package/knowledge/1.0/apps/tools/architecture.md +12 -3
- package/knowledge/1.0/apps/tools/features/compass-user-persona-admin.md +138 -0
- package/knowledge/1.0/apps/tools/features/mvc-data-access-patterns.md +36 -2
- package/knowledge/1.0/apps/tools/features/saml-sso-auth.md +19 -2
- package/knowledge/1.0/apps/worker/INDEX.md +1 -1
- package/knowledge/1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md +156 -11
- package/knowledge/1.0/apps/worker/workflows/tracing-a-worker-cron-run-in-production.md +22 -1
- package/knowledge/2.0/apps/_underscore/INDEX.md +1 -0
- package/knowledge/2.0/apps/_underscore/features/effective-persona-resolution.md +88 -0
- package/knowledge/2.0/apps/_underscore/features/record-change-audit-log.md +18 -3
- package/knowledge/2.0/apps/dbchanges2/features/rerunnable-additive-inserts.md +14 -5
- package/knowledge/2.0/apps/worker2/features/all-client-email-queue-monitor.md +15 -2
- package/knowledge/2.0/apps/worker2/features/oneuptime-worker2-monitoring.md +68 -6
- package/knowledge/2.0/apps/worker2/features/tracking-status-refresh.md +45 -1
- package/knowledge/INDEX.md +2 -2
- package/knowledge/clients/compass-canada/INDEX.md +2 -2
- package/knowledge/clients/compass-canada/features/french-order-email-localization.md +2 -2
- package/knowledge/clients/compass-canada/features/grand-and-toy-asn-import.md +2 -2
- package/knowledge/clients/compass-canada/features/order-fulfillment-status-per-line.md +1 -1
- package/knowledge/clients/compass-canada/profile.md +3 -2
- package/knowledge/clients/compass-usa/INDEX.md +1 -0
- package/knowledge/clients/compass-usa/features/contact-email-resolution.md +149 -0
- package/knowledge/clients/compass-usa/features/order-fulfillment-status-per-line.md +1 -1
- package/knowledge/clients/compass-usa/features/people-file-user-lifecycle.md +35 -5
- package/knowledge/clients/compass-usa/features/persona-model-and-levy-gating.md +16 -2
- package/knowledge/clients/compass-usa/profile.md +3 -2
- package/knowledge/clients/compass-usa/workflows/granting-persona-bundle-access.md +15 -2
- 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-
|
|
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
|
|
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-
|
|
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-
|
|
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/
|
|
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-
|
|
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/
|
|
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
|
-
|
|
39
|
-
—
|
|
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
|
|
47
|
-
(`AdvanceShippingNoticeItemUnits_TrackingNumbers`), one row per `TrackingNumbers.number
|
|
48
|
-
|
|
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/
|
|
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-
|
|
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 |
|