toga-ai 1.0.509 → 1.0.511

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
- | [Tools (1.0 Internal-Tools App) Architecture](architecture.md) | **Tools** is a standalone 1.0 (`App_`) application that houses many small internal tools behind simple interfaces, gated by Client_True staff persona. | tools/index.php, tools/_/app/framework.php, tools/_/app/frameworkindex.php, tools/assets/img/favicon/favicon.ico, tools/assets/img/favicon/favicon-32x32.png, tools/assets/img/favicon/favicon-16x16.png, tools/assets/img/favicon/apple-touch-icon.png, tools/_/app/auth.php, tools/_/app/nav.php, tools/common/header.php, tools/common/footer.php, tools/mvc/get.php, tools/mvc/_TEMPLATE/get.php, tools/docs/ADDING_A_TOOL.md |
5
+ | [Tools (1.0 Internal-Tools App) Architecture](architecture.md) | **Tools** is a standalone 1.0 (`App_`) application that houses many small internal tools behind simple interfaces, gated by Client_True staff persona. | tools/index.php, tools/_/app/framework.php, tools/_/app/frameworkindex.php, tools/assets/img/favicon/favicon.ico, tools/assets/img/favicon/favicon-32x32.png, tools/assets/img/favicon/favicon-16x16.png, tools/assets/img/favicon/apple-touch-icon.png, tools/_/app/auth.php, tools/_/app/nav.php, tools/common/header.php, tools/common/footer.php, tools/mvc/get.php, tools/mvc/_TEMPLATE/get.php, tools/docs/ADDING_A_TOOL.md, tools/assets/css/theme.css, tools/assets/js/theme.js |
6
6
  | [ClickUp Sprint Dashboard (tools /clickup/react)](features/clickup-sprint-dashboard.md) | A native **ClickUp sprint dashboard** in the 1.0 `tools` app at route `/clickup/react`, built for a wall-mounted **TV display** ("TOGa IQ" internal sprint metri | 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/clickup/react/get.php, tools/mvc/login/get.php, tools/_/app/nav.php |
7
7
  | [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 |
8
8
  | [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 |
@@ -14,4 +14,5 @@
14
14
  | [Tools SAML SSO Consumer & Persona-Gated Auth (App_Auth)](features/saml-sso-auth.md) | `App_Auth` is the Tools app's authentication layer: it consumes the SAML gateway `?saml=` handoff (see the 2.0 SAML downstream integration contract), establishe | tools/_/app/auth.php, tools/mvc/sso/initiate/get.php, tools/mvc/sso/get.php, tools/mvc/login/get.php, tools/mvc/login/post.php, tools/mvc/logout/get.php, tools/mvc/get.php, tools/config.production.ini, tools/config.local.ini |
15
15
  | [Talos Knowledge Base Admin UI (KB Documents + Vocabulary)](features/talos-kb-documents-admin.md) | > **PER-AI-MODEL, DATA-DRIVEN SCOPING (2026-07-29).** The KB-documents and Vocabulary admin > UIs were refactored from a single hard-coded **"development-team"* | tools/mvc/talos/kb-documents/get.php, tools/mvc/talos/kb-documents/post.php, tools/mvc/talos/knowledge-bases/get.php, tools/mvc/talos/knowledge-bases/post.php, tools/mvc/talos/vocabulary/get.php, tools/mvc/talos/vocabulary/post.php, tools/_/app/talos/s3.php, tools/_/app/talos/bedrock.php, tools/_/app/pg.php, tools/_/app/model/true/aimodels.php, tools/_/app/model/true/vectorindexes.php, tools/_/app/model/true/aimodels_vectorindexes.php, tools/_/app/worker.php, tools/_/app/nav.php, tools/config.production.ini, tools/config.alpha.ini, tools/.platform/httpd/conf.d/timeouts.conf, tools/.platform/hooks/prebuild/01-install-php-pgsql.sh, tools/.platform/hooks/postdeploy/01-restart-php.sh |
16
16
  | [Talos Pricing UI (Contracts, Pricing Dashboard, Usage, Settings + Estimator)](features/talos-pricing-ui.md) | The 1.0 (tools app) face of the **Talos Pricing Platform** — a "Talos Pricing" nav folder with four pages plus the estimate engine. | tools/_/app/nav.php, tools/_/app/talos/estimator.php, tools/_/app/talos/usage.php, tools/mvc/talos/contracts/get.php, tools/mvc/talos/contracts/post.php, tools/mvc/talos/pricing/get.php, tools/mvc/talos/usage/get.php, tools/mvc/talos/settings/get.php, tools/mvc/talos/settings/post.php, tools/assets/css/style.css |
17
+ | [App-Wide Colour Theme (light / dark / auto)](features/theme-light-dark.md) | A **light / dark / auto** colour theme for the *entire* Tools app, built as a single semantic-token layer (`assets/css/theme.css`) rather than per-page edits. | tools/assets/css/theme.css, tools/assets/js/theme.js, tools/_/app/frameworkindex.php, tools/_/app/nav.php, tools/common/header.php, tools/mvc/login/get.php, tools/assets/css/style.css, tools/assets/css/design.css, tools/assets/css/login.css, tools/assets/css/cloudfront-clients.css, tools/assets/css/gchart.css, tools/mvc/talos/usage/get.php, tools/mvc/talos/pricing/get.php, tools/mvc/talos/contracts/get.php, tools/mvc/clickup/dashboard/get.php, tools/mvc/email-migration/notify/post.php |
17
18
  | [Deploying Tools to Elastic Beanstalk (PHP 8.5 / Amazon Linux 2023)](workflows/deploy-to-elastic-beanstalk-al2023.md) | How the **Tools** 1.0 app boots on Elastic Beanstalk running `PHP 8.5 on 64bit Amazon Linux 2023/4.13.1 (aarch64)`. | tools/.ebextensions/004_http_to_https.config, tools/.ebextensions/006_mount-s3fs.config, tools/.ebextensions/007_setup_export_cache_folders.config, tools/.ebextensions/008_setup_ldap.config, tools/.ebextensions/009_setup_phpini.config, tools/.ebextensions/020_setup_git_libraries.config, tools/.ebextensions/050_register_instance_to_shared_application_load_balancer.config, tools/ebs/git.json, tools/ebs/setup_export_cache_folders.php, tools/_/app/frameworkindex.php |
@@ -6,7 +6,7 @@ project: Tools
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-07-24
9
+ updated: 2026-08-04
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - tools/index.php
@@ -23,7 +23,10 @@ files:
23
23
  - tools/mvc/get.php
24
24
  - tools/mvc/_TEMPLATE/get.php
25
25
  - tools/docs/ADDING_A_TOOL.md
26
+ - tools/assets/css/theme.css
27
+ - tools/assets/js/theme.js
26
28
  related:
29
+ - ./features/theme-light-dark.md
27
30
  - ./features/saml-sso-auth.md
28
31
  - ./features/persona-gated-navigation.md
29
32
  - ./features/developer-tools.md
@@ -47,7 +50,13 @@ double-gated (`[internal] dev_mode` AND `App_Registry::inDevMode()`). `config.*.
47
50
  (incl. `config.prod.ini`) is committed with plaintext production secrets that were **publicly
48
51
  web-readable** until the front-controller deny blocks added 2026-07-24 (see Known issues);
49
52
  rotation of every exposed secret is still owed. Never add more secrets, and keep config files
50
- denied at the front controller / out of the web root.
53
+ denied at the front controller / out of the web root. New CSS must use `--c-*` tokens from
54
+ `assets/css/theme.css`, never raw colour literals. **`assets/css/style.scss` is DEAD — edit
55
+ `style.css` directly** (the `.css` has drifted ~450 lines ahead; there is no sass binary or
56
+ `package.json`, so recompiling the SCSS would silently revert shipped work).
57
+ `App_FrameworkIndex::setHeaderCode()` is the **only** hook for markup in `<head>`, and it
58
+ **SETS rather than appends** — the theme bootstrap is its sole caller, so a second call
59
+ silently clobbers it.
51
60
 
52
61
  ## Boot & structure
53
62
 
@@ -72,6 +81,14 @@ sibling apps. A fixed left two-column shell: dark left sidebar (`.app-nav`) with
72
81
  persona-filtered nav in the middle, pinned footer with the user's name + a ghost "Log out"
73
82
  button; content area on the right. **No top header bar** (see Critical rules).
74
83
 
84
+ **All colour comes from a semantic token layer, `assets/css/theme.css`**, registered **first**
85
+ in `App_FrameworkIndex_Tools::__construct()` so every other sheet resolves through it. The app
86
+ supports light / dark / auto; the resolved theme is stamped on `<html>` as `data-theme` by an
87
+ inline pre-paint script and the user's choice lives in `localStorage` (`toga-tools-theme`) —
88
+ **not** the DB. The left sidebar and public header stay dark in **both** themes, which is why
89
+ `toga-brandmark-white.png` still works everywhere. Login shows `toga-horizontal-colored.png` in
90
+ light and `toga-horizontal-white.png` in dark. See `features/theme-light-dark.md`.
91
+
75
92
  ## Home route
76
93
 
77
94
  `/` (`mvc/get.php`) renders a persona-filtered dashboard of available tools (tiles grouped by
@@ -106,6 +123,11 @@ Create `mvc/<folder>/<tool>/get.php` (copy `mvc/_TEMPLATE/get.php`), add one ent
106
123
  composer package must `require_once` the autoloader itself.
107
124
 
108
125
  ## Change history
126
+ - 2026-08-04 — Added the app-wide light/dark/auto theme: `assets/css/theme.css` token layer
127
+ registered first in `App_FrameworkIndex_Tools`, pre-paint `data-theme` stamping via the
128
+ one-and-only `setHeaderCode()` hook, `localStorage` preference (no DB column), nav/public
129
+ header dark in both themes. Recorded that `style.scss` is dead and `setHeaderCode()`
130
+ sets-not-appends. Not committed to `tools` yet (jcardinal)
109
131
  - 2026-07-24 — Fixed the config-file web-root exposure: `config.*.ini` was publicly readable via
110
132
  the `!-f`-only front controller (`GET /config.production.ini` served it verbatim); added
111
133
  `<FilesMatch>` deny blocks referencing the new 1.0 back-end security standard. Softened the
@@ -6,8 +6,8 @@ project: Tools
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-29
10
- owners: [kyalamarthi]
9
+ updated: 2026-08-04
10
+ owners: [kyalamarthi, jcardinal]
11
11
  files:
12
12
  - tools/_/app/clickup/sprint.php
13
13
  - tools/v2/sprints/tile/index.php
@@ -24,6 +24,7 @@ related:
24
24
  - ./persona-gated-navigation.md
25
25
  - ./saml-sso-auth.md
26
26
  - ./mvc-data-access-patterns.md
27
+ - ./theme-light-dark.md
27
28
  - ../architecture.md
28
29
  - ../../../2.0/apps/worker2/features/team-sprint-management.md
29
30
  ---
@@ -138,5 +139,15 @@ the React dashboard supersedes it.
138
139
  passed by reference", which the E_ALL error handler promotes to a fatal 500. Assign first:
139
140
  `$res = App_Database::query(...); buildArrayOfRows($res, false);`.
140
141
 
142
+ - **The old Power BI embed page (`mvc/clickup/dashboard/get.php`) keeps ~6 fixed colour
143
+ literals on purpose — do NOT theme them.** Its card/frame colour `#0b1020` is matched to the
144
+ embedded Power BI report canvas so nothing flashes white while the iframe loads. Themed
145
+ surfaces there would reintroduce the flash in one theme or the other. Everything else on the
146
+ page was tokenised for the app-wide theme — see
147
+ [App-Wide Colour Theme](./theme-light-dark.md).
148
+
141
149
  ## Change history
150
+ - 2026-08-04 — Tokenised the `/clickup/dashboard` page-local styles for the app-wide light/dark
151
+ theme, keeping ~6 deliberate fixed literals (incl. the `#0b1020` frame that matches the
152
+ embedded Power BI canvas so the iframe load does not flash white). Not committed. (jcardinal)
142
153
  - 2026-07-29 — Built `/clickup/react` as a native tools feature (data engine, `/v2/sprints/*` routes, CDN-React TV UI, iframe embed, "Dashboard/Clickup" nav); removed the old `/clickup/dashboard` Power BI nav entry; documented two reusable 1.0 gotchas (queryLogging preflight on Defaults-less DBs, buildArrayOfRows by-ref). (kyalamarthi)
@@ -208,5 +208,4 @@ None — internal/shared design-team tool.
208
208
  - [Tools Architecture](../architecture.md)
209
209
  - [Static Demo Hosting](../../../standalone/apps/forward/features/static-demo-hosting.md)
210
210
  - [Deploying Tools to Elastic Beanstalk (AL2023)](../workflows/deploy-to-elastic-beanstalk-al2023.md)
211
- </content>
212
- </invoke>
211
+
@@ -6,14 +6,15 @@ project: Tools
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-03
10
- owners: [bala]
9
+ updated: 2026-08-04
10
+ owners: [bala, jcardinal]
11
11
  files:
12
12
  - tools/mvc/email-migration/notify/get.php
13
13
  - tools/mvc/email-migration/notify/post.php
14
14
  - tools/_/app/nav.php
15
15
  related:
16
16
  - ./mvc-data-access-patterns.md
17
+ - ./theme-light-dark.md
17
18
  - ../workflows/deploy-to-elastic-beanstalk-al2023.md
18
19
  - ../architecture.md
19
20
  ---
@@ -104,7 +105,19 @@ endpoint that swallows non-fatal diagnostics (returns `true`) for the duration o
104
105
  fatals and thrown exceptions still reach the `catch`. The framework mechanism is documented in
105
106
  [mvc-data-access-patterns](./mvc-data-access-patterns.md).
106
107
 
108
+ ## Gotcha — the colour literals in `post.php` are EMAIL HTML; never tokenise them
109
+
110
+ `post.php` builds the digest **email** body. Email clients cannot resolve CSS custom
111
+ properties, so its colour literals must stay hard-coded — a `var(--c-…)` there renders as no
112
+ colour at all. This is easy to get wrong because the on-screen `get.php` in the same folder
113
+ *was* tokenised for the app-wide theme (see
114
+ [App-Wide Colour Theme](./theme-light-dark.md)). Rule: styles that leave the browser are not
115
+ themed.
116
+
107
117
  ## Change history
118
+ - 2026-08-04 — Tokenised the on-screen `get.php` page styles for the app-wide light/dark theme
119
+ and recorded that `post.php`'s email-HTML colour literals must stay hard-coded (email clients
120
+ cannot resolve CSS custom properties). Not committed. (jcardinal)
108
121
  - 2026-08-03 — Built the Legacy Email Notifier: multi-file xlsx merge via vendored PhpSpreadsheet,
109
122
  legacy-domain filter, `Client_True.Users` local-part resolution with always-domain-swap
110
123
  (never-skip), one digest per person per legacy domain, and chunked (25/batch) browser-driven
@@ -20,6 +20,7 @@ files:
20
20
  - tools/mvc/talos/settings/post.php
21
21
  - tools/assets/css/style.css
22
22
  related:
23
+ - ./theme-light-dark.md
23
24
  - ./persona-gated-navigation.md
24
25
  - ./mvc-data-access-patterns.md
25
26
  - ../architecture.md
@@ -97,6 +98,20 @@ about cost; cost is driven by tool-call **intensity** via the profile
97
98
  - **Voice multipliers (0.25 / 0.50) are UNVALIDATED** — supplied second-hand, never measured,
98
99
  and possibly inverted. Flagged in the UI. **Do not quote voice from them.**
99
100
  - **`db_team`** is read/write on Contracts and Settings, read-only on the dashboards.
101
+ - **Chart.js colours must come from theme tokens, and a live theme switch needs
102
+ `chart.update()`.** These pages now read series/axis/tick/gridline colours via
103
+ `TogaTheme.color('--c-…')` inside a `paint()` registered with `TogaTheme.onChange(paint)`,
104
+ then call `chart.update()` — `update()` clears Chart.js's resolver cache, so without it a
105
+ `Chart.defaults` change does not reach charts that already exist. Chart.js otherwise defaults
106
+ axis/tick text to a dark colour that is unreadable in dark mode. See
107
+ [App-Wide Colour Theme](./theme-light-dark.md).
108
+ - **These pages keep an explicit `--c-bs-*` token group** because they were authored against
109
+ Bootstrap's palette (`#0d6efd`, `#6c757d`, `#495057`, `#dc3545`, `#198754`…), which is close
110
+ to but not the TOGA brand palette — folding them into `--c-primary` visibly restyles them.
111
+ - **Open (dark mode):** the error toast measures ~2.4:1 (white on `--c-danger`) and likely
112
+ wants a fixed dark fill; the Pricing Dashboard target-band dataset never had a
113
+ `backgroundColor` (pre-existing authoring slip) so the band shades rather than tints; the
114
+ three chart pages cannot be statically proven light-identical and still need a browser pass.
100
115
 
101
116
  ## Security
102
117
  - `config.*.ini` carries committed plaintext secrets (team-accepted, see architecture Known
@@ -104,5 +119,10 @@ about cost; cost is driven by tool-call **intensity** via the profile
104
119
  (`[api] talos_backend_url` / `talos_backend_key`); no values recorded. Do not add secrets.
105
120
 
106
121
  ## Change history
122
+ - 2026-08-04 — Tokenised these pages for the new app-wide light/dark theme: page-local styles
123
+ now use `--c-*` tokens (Talos pages keep their own `--c-bs-*` Bootstrap ramp), and the three
124
+ Chart.js pages read colours via `TogaTheme.color()` + repaint on `TogaTheme.onChange()` with
125
+ `chart.update()`. Recorded the dark-mode open items (error-toast contrast, missing
126
+ target-band `backgroundColor`). Not committed. (jcardinal)
107
127
  - 2026-08-04 — Restructured to **Contracts / Pricing Dashboard / Usage / Pricing Settings**. New Contracts pages replace Onboarding with a real PROSPECT/ACTIVE/CHURNED lifecycle (signed contracts churn, never hard-delete — `TalosPricingBands` cascades; post-signature edits append to `TalosContractAmendments`), plus actual-vs-estimate variance, month history chart and one-click apply-recommended-org-fee. Settings replaces the Cost Factors editor with measured rows read-only (`UPDATE ... AND isDerived = 0`). Benchmarks page deleted. Pricing Dashboard rebuilt for sales leadership. Fixed `usage.php clientOptions()` reading an always-empty `TalosClients` (selector rendered empty) → `GET /api/usage/clients` with Team-DB fallback; added cost-intensity panel. Estimator moved to token unit price + workload profiles. Wrote the missing `.app-content:has(.tool--wide)` rule and consolidated Talos CSS into `style.css`. (jcardinal)
108
128
  - 2026-06-29 — Built the Talos Pricing UI: nav folder + 4 pages (Onboarding w/ live JS estimate and contract-signing that locks the band schedule into `TalosClients`+`TalosPricingBands` in one txn; read-only Pricing Dashboard + Usage Benchmarks; technical-only Cost Factors editor) and `App_Talos_Estimator` (reads `TalosCostFactors`, derives conversations/user so sales need not enter it). Reads Team DB via new `db_team` connection. Offerings CHAT/VOICE_TO_VOICE/NATURAL_VOICE = 1.0/0.25/0.50 (voice ambiguous, deferred). (jcardinal)
@@ -0,0 +1,209 @@
1
+ ---
2
+ title: App-Wide Colour Theme (light / dark / auto)
3
+ framework: "1.0"
4
+ repo: tools
5
+ project: Tools
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-04
10
+ owners: [jcardinal]
11
+ files:
12
+ - tools/assets/css/theme.css
13
+ - tools/assets/js/theme.js
14
+ - tools/_/app/frameworkindex.php
15
+ - tools/_/app/nav.php
16
+ - tools/common/header.php
17
+ - tools/mvc/login/get.php
18
+ - tools/assets/css/style.css
19
+ - tools/assets/css/design.css
20
+ - tools/assets/css/login.css
21
+ - tools/assets/css/cloudfront-clients.css
22
+ - tools/assets/css/gchart.css
23
+ - tools/mvc/talos/usage/get.php
24
+ - tools/mvc/talos/pricing/get.php
25
+ - tools/mvc/talos/contracts/get.php
26
+ - tools/mvc/clickup/dashboard/get.php
27
+ - tools/mvc/email-migration/notify/post.php
28
+ related:
29
+ - ../architecture.md
30
+ - ./persona-gated-navigation.md
31
+ - ./talos-pricing-ui.md
32
+ - ./clickup-sprint-dashboard.md
33
+ - ./legacy-email-notifier.md
34
+ - ./design-demo-admin.md
35
+ - ../../../standards/frontend.md
36
+ ---
37
+
38
+ ## Summary
39
+
40
+ A **light / dark / auto** colour theme for the *entire* Tools app, built as a single
41
+ semantic-token layer (`assets/css/theme.css`) rather than per-page edits. The user's choice
42
+ is a three-way switcher rendered next to their name in the left-nav footer, persisted in
43
+ **`localStorage` (`toga-tools-theme`) — deliberately not a DB column**, and applied
44
+ **before first paint** by an inline bootstrap script so there is no flash of the wrong theme.
45
+
46
+ **Light mode is a hard constraint: pixel-identical to before the change**, verified
47
+ mechanically (see [Verification](#verification-how-to-prove-light-mode-did-not-change)).
48
+ Any new page or stylesheet in this app should consume `--c-*` tokens and will then theme for
49
+ free.
50
+
51
+ > **Status (2026-08-04): NOT COMMITTED.** All of this lives in the working tree of the local
52
+ > `tools` checkout, which is on `_production` — it needs a feature branch before it lands.
53
+ > Verified **statically only**: resolver diffs, contrast maths, `php -l` on the 14 changed PHP
54
+ > files, `node --check` on `theme.js`. **No browser check and no specialist-reviewer pass has
55
+ > been run.** See [Open items](#open-items).
56
+
57
+ ## How it works
58
+
59
+ ### 1. The token layer — `assets/css/theme.css`
60
+
61
+ ~142 semantic CSS custom properties are declared on `:root` **with the original light
62
+ values**, and overridden in a `:root[data-theme="dark"]` block. It is registered **first** in
63
+ `App_FrameworkIndex_Tools::__construct()` so every other stylesheet resolves through it.
64
+
65
+ Token groups and their meaning:
66
+
67
+ | Group | Meaning |
68
+ | --- | --- |
69
+ | `--c-surface*` | fills |
70
+ | `--c-text*` | foregrounds |
71
+ | `--c-border*` | borders |
72
+ | `--c-primary*` | brand (`-tNN` = alpha tint) |
73
+ | `--c-nav-*` | left nav / public header — **dark in BOTH themes** |
74
+ | `--c-on-accent`, `--c-on-dark-tNN` | white in BOTH themes; content sitting on a permanently dark/coloured fill |
75
+ | `--c-slate-*`, `--c-indigo-*`, `--c-green-*`, `--c-red-*` | secondary ramp used by `design.css` + `cloudfront-clients.css` |
76
+ | `--c-bs-*` | legacy Bootstrap ramp, Talos pages only (see gotchas) |
77
+ | `--c-code-bg`, `--c-code-fg` | fixed dark code slab (not themed) |
78
+
79
+ `color-scheme` is itself tokenised as `--c-color-scheme` so native scrollbars and form
80
+ controls follow the theme. Note `style.css` previously hard-coded `color-scheme: light` on
81
+ `html, body`, which beat the `:root` token — that had to be removed.
82
+
83
+ ### 2. Pre-paint application (no flash)
84
+
85
+ `App_FrameworkIndex_Tools::themeBootstrapScript()` returns a tiny inline `<script>` injected
86
+ with `App_FrameworkIndex::setHeaderCode()`; `library` echoes it inside `<head>` **after** the
87
+ CSS/JS includes. It reads `localStorage` and stamps `data-theme` + `data-theme-pref` onto
88
+ `<html>` before the first paint. **An external JS file cannot do this** — it runs too late and
89
+ you get a flash of the wrong theme.
90
+
91
+ ### 3. Runtime API — `assets/js/theme.js` (`window.TogaTheme`)
92
+
93
+ - `get()` → `'light' | 'dark'` (resolved theme)
94
+ - `getPreference()` → `'light' | 'dark' | 'system'`
95
+ - `set(pref)` — persists + applies
96
+ - `onChange(fn)` — **fires immediately with the current theme**, then on every change, so a
97
+ caller has one code path for both "colour yourself now" and "recolour"
98
+ - `color('--token')` — read a themed colour out of CSS for JS-driven visuals
99
+
100
+ Persistence: `localStorage` key **`toga-tools-theme`**, values `light|dark|system`. Every
101
+ access is `try`/`catch`ed — Safari private mode *throws* rather than returning `null`.
102
+ `system` follows the OS live via a `matchMedia('(prefers-color-scheme: dark)')` change
103
+ listener (with the `addListener` fallback for Safari <14), and a `storage` event listener keeps
104
+ multiple open tabs in agreement.
105
+
106
+ ### 4. The switcher (server-rendered)
107
+
108
+ Rendered in `common/header.php` inside `.app-nav-footer` — next to the signed-in user's name,
109
+ above **Log out** — as a `role="radiogroup"` of three buttons carrying
110
+ `data-theme-choice="light|dark|system"` (labelled **Light / Dark / Auto**). `theme.js` manages
111
+ `aria-checked` + a roving `tabindex` and supports arrow keys. Icons come from a new
112
+ `App_Nav::themeIcon($theme)` that returns **inline SVG** from a fixed 3-key allowlist — inline
113
+ so the glyph inherits `currentColor`.
114
+
115
+ ### 5. Login-page wordmark
116
+
117
+ Two `<img>` wordmarks are in the markup and CSS shows one per theme
118
+ (`.login-logo--light` / `.login-logo--dark`, keyed off `:root[data-theme="dark"]`):
119
+ `toga-horizontal-colored.png` in light, `toga-horizontal-white.png` in dark.
120
+
121
+ Chosen over swapping `src` in JS or using `<picture>`: CSS honours an **explicit** Light/Dark
122
+ choice, whereas a `<picture>` media query only sees `prefers-color-scheme` and would ignore a
123
+ forced override — and CSS cannot flash the wrong logo. Both PNGs are 3610×700 so there is no
124
+ reflow. Tradeoff: the browser downloads both (~49 KB extra).
125
+
126
+ ### 6. Chart.js pages
127
+
128
+ `talos/usage`, `talos/pricing` and `talos/contracts` read series/axis/tick/gridline colours
129
+ from tokens via `TogaTheme.color()` inside a `paint()` function registered with
130
+ `TogaTheme.onChange(paint)`, followed by `chart.update()`. **`chart.update()` is required** —
131
+ it clears Chart.js's resolver cache so `Chart.defaults` changes take effect on charts that
132
+ already exist. Without this, Chart.js defaults axis/tick text to a dark colour that is
133
+ unreadable on a dark background.
134
+
135
+ ## Decisions
136
+
137
+ - **Left nav and public header stay dark in BOTH themes** (deepened for dark). Preserves the
138
+ app's identity, guarantees zero light-mode regression there, and keeps the existing white
139
+ brandmark PNG usable in both themes.
140
+ - **Preference is browser-local (`localStorage`), not a DB column** — explicit developer
141
+ requirement.
142
+ - **Light mode must be pixel-identical**, proven mechanically rather than by eye.
143
+ - **`assets/css/style.scss` left untouched** — neither revived nor deleted (out of scope; that
144
+ is already the status quo — see gotcha below).
145
+
146
+ ## Verification (how to prove light mode did not change)
147
+
148
+ 1. **Resolver diff.** Resolve every `var(--c-*)` back through the `:root` (light) block and
149
+ diff the result against the pre-change file. Identical output = light mode unchanged.
150
+ 2. **Dark-coverage assert.** Every `:root` token must have a `[data-theme="dark"]` override
151
+ unless it is intentionally fixed.
152
+ 3. **Per-file colour multiset vs. `git HEAD`.** Compare the *multiset* of resolved colour
153
+ values per file, not a line-by-line diff — an index/line-based diff is useless once lines
154
+ are inserted, whereas a multiset comparison is immune to line-number shifts.
155
+
156
+ ## Gotchas
157
+
158
+ - **`assets/css/style.scss` is DEAD in this repo — edit `style.css` directly.** Many recent
159
+ commits touch the `.css` while the `.scss` was last touched in an old ticket; the `.css` has
160
+ drifted ~450 lines ahead. There is no sass binary and no `package.json`. "Helpfully"
161
+ recompiling the SCSS would **silently revert a large amount of shipped work**.
162
+ - **`App_FrameworkIndex::setHeaderCode()` is the only hook for injecting markup into `<head>`
163
+ in 1.0** (library echoes `$this->headerCode` inside `<head>` after the CSS/JS includes). It
164
+ **SETS rather than appends**, and before this feature it was unused anywhere in tools — so a
165
+ **second caller silently clobbers the first**. If another feature needs head markup, refactor
166
+ to compose both strings; do not add a second `setHeaderCode()` call.
167
+ - **A colour literal used as both a foreground and a background must be split into two
168
+ tokens**, or dark mode inverts it the wrong way. Real cases in tools: `#10113A` (body text
169
+ *and* nav/public-header background), `#20242c` (text-strong *and* a dark chip fill), `#999`
170
+ (text grey *and* a badge fill), `#eef0f4` (border *and* badge fill), `#0f172a` (design.css
171
+ `--d-ink` text *and* the code-block background). Detect these **mechanically** — collect, per
172
+ candidate token, the set of CSS properties it appears with (background vs. colour/border).
173
+ Do not eyeball it.
174
+ - **A brand colour needs TWO tokens in a dark theme.** `--c-primary` (text/borders on a panel)
175
+ is **lightened** in dark for legibility; `--c-primary-fill` (a fill behind white text) is
176
+ kept **dark enough** in dark mode to preserve contrast. The roles pull in opposite
177
+ directions — one token makes white-on-brand buttons fail contrast.
178
+ - **The Talos admin pages keep their own `--c-bs-*` group.** They were authored against
179
+ Bootstrap's palette (`#0d6efd`, `#6c757d`, `#495057`, `#dc3545`, `#198754`…), which is close
180
+ to but **not** the TOGA brand palette; folding them into `--c-primary` visibly restyles those
181
+ pages. Only map a literal onto a token whose light value is **identical**.
182
+ - **`mvc/email-migration/notify/post.php` generates EMAIL HTML — its colour literals must stay
183
+ hard-coded.** Email clients cannot resolve CSS custom properties. It is the same file family
184
+ as the on-screen page, so it is easy to tokenise by mistake.
185
+ - **`mvc/clickup/dashboard/get.php` keeps ~6 fixed literals on purpose.** Its card/frame colour
186
+ `#0b1020` matches the embedded Power BI report canvas so nothing flashes white while the
187
+ iframe loads. Do not theme those.
188
+
189
+ ## Open items
190
+
191
+ - The three Chart.js pages **cannot** be statically proven light-identical — their colours are
192
+ now generated at runtime. Needs a browser pass.
193
+ - The Talos error toast measures ~2.4:1 in dark (white text on `--c-danger`) and probably wants
194
+ a fixed dark fill.
195
+ - `talos/pricing`'s target-band dataset never had a `backgroundColor` (a pre-existing authoring
196
+ slip), so its band renders as a faint darkening rather than a green tint.
197
+ - A few Talos overlay shadows shifted from black-based to slate-based (imperceptible but real),
198
+ and 7 imperceptible colour substitutions were accepted on Talos pages (largest:
199
+ `#f8f9fa` → `#f8f9fb`).
200
+
201
+ ## Change history
202
+ - 2026-08-04 — Built the app-wide light/dark/auto theme: `assets/css/theme.css` (~142 `--c-*`
203
+ tokens on `:root` + a `[data-theme="dark"]` override, registered first in
204
+ `App_FrameworkIndex_Tools`), `assets/js/theme.js` (`window.TogaTheme`), a pre-paint inline
205
+ bootstrap via `setHeaderCode()`, a Light/Dark/Auto radiogroup in the nav footer
206
+ (`App_Nav::themeIcon()`), per-theme login wordmarks, and token-driven Chart.js repaint on the
207
+ three Talos chart pages. Preference in `localStorage` (`toga-tools-theme`), no DB column; nav
208
+ + public header stay dark in both themes; light mode proven unchanged by resolver diff.
209
+ **Not committed — working tree on `_production`, no browser verification yet.** (jcardinal)
@@ -5,12 +5,13 @@ project: Library
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-07-30
8
+ updated: 2026-08-04
9
9
  owners: [jcardinal]
10
10
  files: []
11
11
  related:
12
12
  - ./backend-php.md
13
13
  - ./framework-rules.md
14
+ - ../apps/tools/features/theme-light-dark.md
14
15
  - ../apps/tools/features/design-demo-admin.md
15
16
  - ../apps/tools/features/talos-kb-documents-admin.md
16
17
  ---
@@ -66,10 +67,48 @@ Each round trip adds a level. Equality comparisons then fail too, because a fres
66
67
  - `tools/_/app/design/github.php` — `App_Design_Github::decodeEntities()` applied to tab
67
68
  names and project titles on both the storage-read and request-read sides.
68
69
 
70
+ ## Tokenising an existing stylesheet for a second theme
71
+
72
+ When retrofitting light/dark onto a legacy sheet, four rules turn a guessing game into a
73
+ mechanical exercise.
74
+
75
+ **1. Split any literal used as both a foreground and a background into two tokens.** A single
76
+ token inverts the wrong way in the second theme. Detect these **mechanically** — collect, per
77
+ candidate token, the set of CSS properties it appears with (`background*` vs.
78
+ `color`/`border*`) — do not eyeball it. Real cases in `tools`: `#10113A` (body text *and* nav
79
+ background), `#20242c`, `#999`, `#eef0f4`, `#0f172a`.
80
+
81
+ **2. A brand colour needs TWO tokens.** `--c-primary` (text/borders on a panel) must be
82
+ **lightened** in dark for legibility; `--c-primary-fill` (a fill behind white text) must stay
83
+ **dark enough** in dark to preserve contrast. The roles pull in opposite directions; one token
84
+ makes white-on-brand buttons fail contrast.
85
+
86
+ **3. Only map a literal onto a token whose light value is IDENTICAL.** Measure perceptual
87
+ distance before accepting a "close enough" substitution — a near-match silently restyles the
88
+ page. Legacy pages authored against a *different* palette (the Talos admin pages use
89
+ Bootstrap's `#0d6efd`/`#6c757d`/`#dc3545`/`#198754`) keep their own token group with their
90
+ exact original values rather than being folded into the brand tokens.
91
+
92
+ **4. Prove the original theme is unchanged, mechanically.** (a) Resolve every `var(--…)` back
93
+ through the `:root` block and diff against the pre-change file; (b) assert every `:root` token
94
+ has a second-theme override unless intentionally fixed; (c) compare the per-file **multiset**
95
+ of resolved colour values against `git HEAD` — a line-index diff is useless once lines are
96
+ inserted, a multiset comparison is immune to line shifts.
97
+
98
+ Two things are **never** tokenised: HTML destined for an **email** (email clients cannot
99
+ resolve custom properties) and colours deliberately matched to third-party embedded content
100
+ (e.g. an iframe's canvas colour, to prevent a white flash on load).
101
+
102
+ Reference implementation:
103
+ [tools App-Wide Colour Theme](../apps/tools/features/theme-light-dark.md).
104
+
69
105
  ## Change history
70
106
 
107
+ - 2026-08-04 — Added "Tokenising an existing stylesheet for a second theme": split
108
+ dual-role colour literals, two tokens per brand colour, identical-value-only substitutions,
109
+ and the mechanical proof that the original theme is unchanged; plus the never-tokenise cases
110
+ (email HTML, colours matched to third-party embeds). Drawn from the `tools` light/dark
111
+ build. (jcardinal)
71
112
  - 2026-07-30 — Created. Establishes the escape-once/plain-text-storage invariant after a
72
113
  second independent occurrence in `tools` (Design Demo Admin, following the Talos
73
114
  vocabulary tool). (jcardinal)
74
- </content>
75
- </invoke>
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-03
9
+ updated: 2026-08-04
10
10
  owners: ["mhammontree", "dfranks"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -82,6 +82,50 @@ tenant's other APIs at all. See
82
82
  > result above is authoritative; the code path is misleading. Anyone tempted to reason it out
83
83
  > from V2.php will reach the wrong answer.
84
84
 
85
+ ## The dispatch is UNGUARDED — a row naming a method that does not exist hard-fatals the endpoint
86
+
87
+ The derived name (step 2 above) is **pure convention with nothing validating it**, and there is
88
+ **no `phpMethod` column in production** to override it. All **four** call sites in
89
+ `api2/Component/Api/V2/V2.php` invoke the derived name **without a `method_exists()` check**:
90
+
91
+ | Site | Phase |
92
+ |------|-------|
93
+ | `~L8655` | builds the interceptor lookup |
94
+ | `~L3148` | pre-processing dispatch |
95
+ | `~L5694` | post-processing dispatch |
96
+ | `~L7411` / `~L7437` | alternate pre-processing path |
97
+
98
+ So a **single `ApiPayloadInterceptors` row whose (recordId, phase, method) resolves to a method the
99
+ model does not define takes the endpoint down** with a PHP fatal —
100
+ `Call to undefined method _Model_<Slug>_<Model>::postPost()` — not a handled 4xx/5xx. This is the
101
+ mirror image of the "missing row" failure mode above: a *missing* row is silent, an *extra/wrong*
102
+ row is fatal.
103
+
104
+ **Fix direction:** guard all four sites with `method_exists()` and raise a logged `Logs.Issue`
105
+ instead of letting the request fatal.
106
+
107
+ ### The client-model resolution that decides which class must define the method
108
+
109
+ At `~L5681-5690` the engine derives the concrete class by `str_replace`-ing `_Model_Client_` →
110
+ `_Model_<jwt client slug>_` on `$record->model`, guarded by `class_exists`. For Compass USA that
111
+ yields `_Model_Compass_Usa_*`.
112
+
113
+ The chain is `_Model_Compass_Usa_X extends _Model_Compass_X extends _Model_Client_X`, so **a method
114
+ defined on the shared Compass base is inherited by both the Usa and Canada subclasses** — a
115
+ per-region subclass does *not* need its own copy. Do not add duplicate hooks per region.
116
+
117
+ ### `Core.Records` facts for interceptor debugging
118
+
119
+ - **`Core.Records.model` is UNIQUE** — exactly one record row per model class, so an interceptor's
120
+ `recordId` maps to precisely one model. No ambiguity to resolve.
121
+ - The column is **`route`** (not `routePlural` / `routeSingular`) in the current schema. Example:
122
+ `recordId 15` = route `sales-order-items` = `_Model_Client_SalesOrderItem`.
123
+ - **Diagnostic:** join the client's active interceptor rows to `Core.Records` and check each derived
124
+ method actually exists in `_underscore/Model/<Slug>/`. Compass's legitimate rows (records **14**
125
+ sales-orders, **17** purchase-orders, **19** vendor-items, **21** items, **37** contacts, **55**
126
+ advance-shipping-notices, **179** approval-decisions) all map 1:1 to real methods — **the row whose
127
+ record has no corresponding method is the anomaly.**
128
+
85
129
  ## Worked example — the EV-10 that was not a code bug
86
130
 
87
131
  `_Model_Client_ItemFulfillment::prePost()` defaults `itemFulfillmentStageId` to the shipped stage on
@@ -99,6 +143,14 @@ and the failing environment**. It is a small table, and the drift is usually exa
99
143
  - **⚠ A hook that "isn't running" is a missing registration row before it is a code bug.** Check
100
144
  `ApiPayloadInterceptors` for `(recordId, phase, method)` **first** — never start by re-reading the
101
145
  PHP.
146
+ - **⚠ An interceptor row for a method that does not exist is a production outage, not a no-op.**
147
+ The dispatch is unguarded at all four call sites — the endpoint fatals with *"Call to undefined
148
+ method"*. If a write endpoint suddenly 500s for one client only, list that client's
149
+ `ApiPayloadInterceptors` rows and confirm every derived method exists.
150
+ - **⚠ A migration that enables an interceptor must insert `isActive = 0`.** Activation is a *data*
151
+ change that switches on a *code* path; if the PHP defining the method is not confirmed deployed to
152
+ that environment, the row takes the endpoint down. Insert inactive, verify the deploy, then flip
153
+ `isActive = 1` — and remember every environment activates independently.
102
154
  - **The method name is derived, so a typo'd enum silently misses.** A row with
103
155
  `prePostProcessing = 'PRE'`, `httpMethod = 'PUT'` resolves to `prePut`, not `prePost`; the engine
104
156
  will simply find no method and move on.
@@ -114,6 +166,17 @@ and the failing environment**. It is a small table, and the drift is usually exa
114
166
 
115
167
  ## Change history
116
168
 
169
+ - 2026-08-04 — Documented the **inverse failure mode**: the derived-name dispatch is **unguarded at
170
+ all four `V2.php` call sites** (`~L8655` lookup, `~L3148` pre, `~L5694` post, `~L7411`/`~L7437`
171
+ alternate pre) and there is **no `phpMethod` column in production**, so one row naming a
172
+ nonexistent method hard-fatals the endpoint (*"Call to undefined method
173
+ `_Model_Compass_Usa_SalesOrderItem::postPost()`"*). Added the client-model resolution at
174
+ `~L5681-5690` (`_Model_Client_` → `_Model_<slug>_`, `class_exists`-guarded) and the
175
+ `Usa → Compass → Client` inheritance rule (define once on the Compass base; no per-region copy),
176
+ the `Core.Records` debugging facts (`model` is UNIQUE, column is `route`, record 15 =
177
+ `sales-order-items`, Compass's 1:1 legitimate row set), and the rule that a migration enabling an
178
+ interceptor must land `isActive = 0` first. Diagnosed from a production incident; no code change.
179
+ (dfranks)
117
180
  - 2026-08-03 — Added two verified behaviours from a live dev probe: (1) interceptor registration
118
181
  is **per-API** (`apiId` on the row, in both the Core and `Client_<X>` tables), so a guard in a
119
182
  hook is scoped by **DB config rather than code** and a new row widens its blast radius; (2) the
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-03
9
+ updated: 2026-08-04
10
10
  owners: ["mhammontree", "dfranks"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -15,7 +15,9 @@ files:
15
15
  - _underscore/Model/Core/Logs/Api.php
16
16
  related:
17
17
  - ../architecture.md
18
+ - ./api-payload-interceptors.md
18
19
  - ../../../../clients/aig/features/entitlement-intake.md
20
+ - ../../../../clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md
19
21
  ---
20
22
 
21
23
  ## Summary
@@ -61,6 +63,28 @@ That is the deciding factor when choosing between `_Exception_Validation` (400,
61
63
  request, no Issue) and `_Exception_Business` (500, Sentry + a routed Issue). Do **not** reach for
62
64
  `_Exception_Business` merely to "make sure the rejection is recorded" — it already is.
63
65
 
66
+ ### ⚠ But an uncaught PHP **fatal** can leave NO log row anywhere
67
+
68
+ The commit-the-logs / roll-back-the-data contract above holds for the **handled** paths — a
69
+ validation rejection or an exception the controller catches. It did **not** hold for an **uncaught
70
+ `Error`** observed in production on 2026-08-04 (a `Call to undefined method` raised from an
71
+ [API payload interceptor](api-payload-interceptors.md) dispatch): the fatal was reported to the
72
+ error tracker, but **`Logs_Compass.Api` contained zero 5xx rows for the entire day and the failing
73
+ request appears nowhere in it at all.** Suspected regression in the recent `Error.php` /
74
+ Issue-Event table normalization work.
75
+
76
+ Practical consequences:
77
+
78
+ - **Do not conclude "the request never happened" from an empty log table.** For a fatal, absence of
79
+ a log row is not evidence of absence of the request. Check the error tracker's per-event detail —
80
+ that may be the *only* record of the individual request.
81
+ - **Diagnose from the DATA, not the log.** The data transaction rolls back while a *previous*
82
+ successful request already committed its own rows, so a fatal on a multi-request write sequence
83
+ leaves a detectable **orphan / zero-child parent**. Sweep for those instead of grepping logs — see
84
+ [Compass Order Lifecycle & Data-Integrity Invariants](../../../clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md#detecting-empty-shell-orders-left-by-a-fatal-mid-sequence).
85
+ - **Orphan sweeps only find PARTIAL casualties.** An attempt that rolled back completely leaves no
86
+ row in any table and, with no API log row, no trail at all.
87
+
64
88
  ## Blind spots when auditing a client's API traffic
65
89
 
66
90
  Looking only at `Logs_<Client>.Api` misses four sources:
@@ -110,6 +134,12 @@ re-send.
110
134
 
111
135
  ## Change history
112
136
 
137
+ - 2026-08-04 — Added the limit of the commit-logs contract: an **uncaught PHP fatal** (a
138
+ `Call to undefined method` from an interceptor dispatch) produced **zero rows in
139
+ `Logs_Compass.Api`** — no 5xx row for the whole day and no row for the failing request — while
140
+ still reaching the error tracker. Suspected regression in the `Error.php` / Issue-Event
141
+ normalization work. So log-based diagnosis fails for this class of 500; sweep the **data** for
142
+ zero-child parents instead, and accept that fully-rolled-back attempts leave no trail. (dfranks)
113
143
  - 2026-08-03 — Corrected a common wrong assumption: an **HTTP 400 leaves a full log row**.
114
144
  `Controller/Index.php` L369-377 commits `DB_LOGS`/`DB_CLIENT_LOGS` and rolls back only the
115
145
  business transaction, so rejections are queryable and attributable to the credential; what they
@@ -5,7 +5,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
5
5
  ## 1.0 framework
6
6
 
7
7
  - **library** (Library) _(framework core)_ — 15 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
- - **worker** (Worker) — 19 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
8
+ - **worker** (Worker) — 20 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
9
9
  - **dbchanges** (Database Changes) _(framework core)_ — 1 doc(s) → [1.0/apps/dbchanges/INDEX.md](1.0/apps/dbchanges/INDEX.md)
10
10
  - **worker1.5** (Worker 1.5) — 0 doc(s) → [1.0/apps/worker1.5/INDEX.md](1.0/apps/worker1.5/INDEX.md)
11
11
  - **togadesk** (TOGa Desk) — 11 doc(s) → [1.0/apps/togadesk/INDEX.md](1.0/apps/togadesk/INDEX.md)
@@ -14,7 +14,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
14
14
  - **walmarttechservices** (Walmart Tech Services) — 1 doc(s) → [1.0/apps/walmarttechservices/INDEX.md](1.0/apps/walmarttechservices/INDEX.md)
15
15
  - **test** (Test) — 14 doc(s) → [1.0/apps/test/INDEX.md](1.0/apps/test/INDEX.md)
16
16
  - **toga** (TOGa) — 2 doc(s) → [1.0/apps/toga/INDEX.md](1.0/apps/toga/INDEX.md)
17
- - **tools** (Tools) — 13 doc(s) → [1.0/apps/tools/INDEX.md](1.0/apps/tools/INDEX.md)
17
+ - **tools** (Tools) — 14 doc(s) → [1.0/apps/tools/INDEX.md](1.0/apps/tools/INDEX.md)
18
18
 
19
19
  ## 2.0 framework
20
20
 
@@ -12,5 +12,6 @@
12
12
  | [Compass USA](profile.md) | 2.0 | Compass USA is a TOGA client running a multi-tier supply-chain commerce operation. | |
13
13
  | [Compass Cross-Kit Bundle Corruption — Detection & Repair](workflows/cross-kit-bundle-corruption.md) | 2.0 | A frontend regression in `toga2-commerce`'s edit-order bundle submission mis-attributed bundle (kit) line items and **fees/warranties** to the **wrong kit**, pe | src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts |
14
14
  | [Compass Office Depot Duplicate PO-Line Cleanup (dual-catalog SKU)](workflows/odp-duplicate-po-line-cleanup.md) | 1.0 | The NetSuite fulfillment-sales-order importer duplicated Office Depot purchase-order lines on Compass USA because it reconciled the fulfillment SO against the e | library/app/api/toga2.php, dbchanges2/Client_Compass/2026-07-22a - CleanupOfficeDepotDuplicatePurchaseOrderItems.sql |
15
- | [Compass ODP Order Pipeline to NetSuite (numbered worker crons)](workflows/odp-order-pipeline-to-netsuite.md) | 1.0 | The end-to-end **Compass Office Depot (ODP) order NetSuite** pipeline as it actually runs through the 1.0 `worker` crons under `worker/crons/toga2/compass/`, | worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php, worker/crons/toga2/compass/workflow/2_transmit_mits_purchase_orders_to_vendors.php, worker/crons/toga2/compass/edi/1_download_edi_s3_create_po_toga.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php, worker/crons/toga2/compass/workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php, library/app/client/compass.php |
15
+ | [Recovering a Lost Compass ODP EDI 850 Import (re-drop from Logs.FileLog)](workflows/odp-edi-import-recovery.md) | 1.0 | How to recover a Compass **Office Depot EDI 850** import that failed partway the case where cron **3a** created the ODP SalesOrder header, the follow-up item | worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php, worker/crons/toga2/compass/workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php, worker/schedules/cron.worker.sync.json |
16
+ | [Compass ODP Order Pipeline to NetSuite (numbered worker crons)](workflows/odp-order-pipeline-to-netsuite.md) | 1.0 | The end-to-end **Compass Office Depot (ODP) order → NetSuite** pipeline as it actually runs through the 1.0 `worker` crons under `worker/crons/toga2/compass/`, | worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php, worker/crons/toga2/compass/workflow/2_transmit_mits_purchase_orders_to_vendors.php, worker/crons/toga2/compass/edi/1_download_edi_s3_create_po_toga.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php, worker/crons/toga2/compass/workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php, worker/schedules/cron.worker.sync.json, library/app/client/compass.php |
16
17
  | [Compass Order Lifecycle & Data-Integrity Invariants](workflows/order-lifecycle-and-data-integrity.md) | 2.0 | End-to-end map of how a Compass order flows through the `Client_Compass` (2.0) database and the **expected raw-data shape** at each link/ASN/IF level. | |
@@ -0,0 +1,111 @@
1
+ ---
2
+ title: Recovering a Lost Compass ODP EDI 850 Import (re-drop from Logs.FileLog)
3
+ framework: "1.0"
4
+ repo: worker
5
+ project: Worker
6
+ client: compass-usa
7
+ type: workflow
8
+ status: active
9
+ updated: 2026-08-04
10
+ owners: ["dfranks"]
11
+ files:
12
+ - worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php
13
+ - worker/crons/toga2/compass/workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php
14
+ - worker/schedules/cron.worker.sync.json
15
+ related:
16
+ - clients/compass-usa/workflows/odp-order-pipeline-to-netsuite.md
17
+ - clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md
18
+ - ../../../2.0/apps/api2/features/api-payload-interceptors.md
19
+ - ../../../2.0/apps/api2/features/request-logging.md
20
+ - clients/compass-usa/profile.md
21
+ ---
22
+
23
+ ## Summary
24
+
25
+ How to recover a Compass **Office Depot EDI 850** import that failed partway — the case where cron
26
+ **3a** created the ODP SalesOrder header, the follow-up item write died (e.g. an api2 fatal), and 3a
27
+ then **deleted the S3 object**, leaving a zero-item order and nothing to retry.
28
+
29
+ The recovery works because the raw x12 is archived in `Logs.FileLog` and **3a is fully idempotent**:
30
+ re-uploading the file makes it repair the items, the ODP PO, and both bridges. Verified end-to-end
31
+ in **production** on 2026-08-04 for ODP PO `41608261-1135` / ODP SO `475390815001`.
32
+
33
+ ## When to use this
34
+
35
+ Symptoms: a zero-item ODP SalesOrder (see the orphan sweeps in
36
+ [order lifecycle & data integrity](order-lifecycle-and-data-integrity.md)), and cron 5 re-attempting
37
+ the NetSuite SO every 10 minutes because it selects on `c_dtTransmittedToNetsuite IS NULL`.
38
+
39
+ ## The runbook
40
+
41
+ ### Step 1 — retrieve the archived x12
42
+
43
+ The raw EDI is on the **legacy-core cluster**, table **`Logs.FileLog`**:
44
+
45
+ - `job = 'ODP_EDI'`
46
+ - `fileName = '<odpPoNumber>.x12'` — note **`.x12`**, even though the inbound S3 filename is
47
+ `EDI_<odpPoNumber>.txt`.
48
+
49
+ **Query it bounded by `job` + `fileTimestamp`.** An unbounded `fileName LIKE` scan times out on this
50
+ table.
51
+
52
+ Extract with `mysql --raw --batch --skip-column-names`, then **strip the trailing newline the client
53
+ appends** so the byte count matches the stored `LENGTH(fileData)` exactly. A byte-count mismatch means
54
+ you have a corrupted payload — do not upload it.
55
+
56
+ ### Step 2 — know that S3 has nothing left
57
+
58
+ 3a **deletes the S3 object after processing** (`:769`), so a failed import leaves no file to retry.
59
+ Recovery is a **re-upload**, to:
60
+
61
+ ```
62
+ s3://agilant-as2/OfficeDepot/EDI_<odpPoNumber>.txt
63
+ ```
64
+
65
+ ### Step 3 — clear the cause BEFORE re-uploading
66
+
67
+ **Confirm no `ApiPayloadInterceptors` row exists for the `sales-order-items` record** (`recordId 15`)
68
+ whose derived method the model does not define. If one is still active, the item write fatals again
69
+ **and the file is deleted a second time**. See
70
+ [API payload interceptors](../../../2.0/apps/api2/features/api-payload-interceptors.md).
71
+
72
+ ### Step 4 — let 3a repair it (≤ 5 minutes)
73
+
74
+ 3a runs `*/5`. Because every step is existence-guarded it **reuses** the existing SO and **backfills**
75
+ what is missing: unmatched items (matched case-insensitively by `partNumber`), the ODP PO if absent,
76
+ and both the `PurchaseOrders_SalesOrders` and `PurchaseOrderItems_SalesOrderItems` bridges. No
77
+ duplicates are created.
78
+
79
+ **Then set `customerPurchaseOrder` manually** — 3a never updates the SalesOrder *header* on an
80
+ existing order, so it stays `NULL`. Prefer direct SQL over a `PUT`; the reasoning and its two
81
+ tradeoffs are in the
82
+ [lifecycle doc](order-lifecycle-and-data-integrity.md#repairing-a-header-only-scalar-field-direct-sql-vs-put).
83
+
84
+ ### Step 5 — let cron 5 transmit to NetSuite
85
+
86
+ Cron 5 (`*/10`) picks the now-complete ODP SO up and stamps `c_dtTransmittedToNetsuite` +
87
+ `c_netsuiteInternalSalesOrderId`. It excludes `number LIKE 'MA%'`. Its stamps landing is the
88
+ confirmation the recovery finished.
89
+
90
+ ## Gotchas / known issues
91
+
92
+ - **⚠ Re-uploading before fixing the root cause destroys the file again** (3a deletes on the way
93
+ through). Step 3 is not optional.
94
+ - **⚠ The archive filename extension differs from the S3 one** — `.x12` in `Logs.FileLog`,
95
+ `EDI_<po>.txt` in S3. Searching for the wrong one finds nothing.
96
+ - **Don't search for the ODP SalesOrder by the `BEG` number.** `BEG` is the ODP *PO*; the SO number
97
+ is `REF~QC`. Full segment map in the
98
+ [ODP pipeline doc](odp-order-pipeline-to-netsuite.md).
99
+ - **`Logs.FileLog` has limited retention** (~30 days) — beyond that there is no archived x12 and the
100
+ recovery must come from ODP re-sending.
101
+ - A **fully** rolled-back import leaves no row anywhere and no API log row, so it will not show up in
102
+ an orphan sweep at all; only the error tracker's per-event detail can enumerate those.
103
+
104
+ ## Change history
105
+
106
+ - 2026-08-04 — Documented the runbook after recovering a production ODP order whose item write was
107
+ killed by an api2 fatal: `Logs.FileLog` (`job = 'ODP_EDI'`, `<po>.x12`, bounded by `fileTimestamp`)
108
+ as the x12 archive, the trailing-newline/byte-count check, the re-upload path to
109
+ `s3://agilant-as2/OfficeDepot/`, the mandatory "clear the interceptor first" step, 3a's idempotent
110
+ repair plus the `customerPurchaseOrder` header gap, and cron 5's `*/10` stamps as the completion
111
+ signal. (dfranks)
@@ -6,16 +6,18 @@ project: Worker
6
6
  client: compass-usa
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-08-03
10
- owners: ["rgirish", "bala"]
9
+ updated: 2026-08-04
10
+ owners: ["rgirish", "bala", "dfranks"]
11
11
  files:
12
12
  - worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php
13
13
  - worker/crons/toga2/compass/workflow/2_transmit_mits_purchase_orders_to_vendors.php
14
14
  - worker/crons/toga2/compass/edi/1_download_edi_s3_create_po_toga.php
15
15
  - worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php
16
16
  - worker/crons/toga2/compass/workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php
17
+ - worker/schedules/cron.worker.sync.json
17
18
  - library/app/client/compass.php
18
19
  related:
20
+ - clients/compass-usa/workflows/odp-edi-import-recovery.md
19
21
  - clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md
20
22
  - clients/compass-usa/features/mits-po-transmission-to-vendors.md
21
23
  - clients/compass-usa/features/mits-po-to-so-item-linking.md
@@ -40,7 +42,8 @@ Commerce → Compass SalesOrder (SalesOrders.customerId = 2)
40
42
  MITS → PurchaseOrder (PurchaseOrders.vendorId = 1 = OFFICE DEPOT; POST /v2/purchase-orders; PO number e.g. 50305906-1)
41
43
  [cron 2_transmit_mits_purchase_orders_to_vendors] → ODP (cXML; email fallback per integration) [sets PurchaseOrders.dtSubmitted]
42
44
  ODP → 850 EDI dropped to S3 (AS2) (bucket agilant-as2, prefix OfficeDepot/)
43
- [cron edi/1_download_edi_s3_create_po_toga] → creates ODP SalesOrder (customerId = 1); deletes the S3 file after processing
45
+ [cron workflow/3a_import_office_depot_purchase_orders — NOT edi/1, see correction below]
46
+ → creates ODP SalesOrder (customerId = 1); deletes the S3 file after processing
44
47
  ODP SalesOrder (customerId = 1)
45
48
  [cron 5_create_netsuite_sales_orders_from_office_depot_purchase_orders, hourly] → NetSuite SO
46
49
  writes back c_dtTransmittedToNetsuite + c_netsuiteInternalSalesOrderId ONTO the ODP SO (customerId=1)
@@ -56,12 +59,57 @@ ODP SalesOrder (customerId = 1)
56
59
  `PurchaseOrders.dtSubmitted` when transmitted (~2 min after PO creation). (See the dedicated
57
60
  [MITS PO Transmission to Vendors](../features/mits-po-transmission-to-vendors.md) feature for
58
61
  the item-less-PO gotcha and the CXML-vs-EMAIL routing detail.)
59
- 3. **`edi/1_download_edi_s3_create_po_toga.php`** — pulls the ODP **850** from S3 bucket
60
- `agilant-as2`, prefix `OfficeDepot/` (delivered via AS2), and creates a downstream **ODP
61
- SalesOrder** (customerId=1 = Office Depot). **Deletes the file from S3 after processing**
62
- so an absent S3 object is expected once ingested, not evidence of a miss.
63
- 4. **`workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php`** runs
64
- **hourly**; picks up ODP SalesOrders
62
+ 3. **`workflow/3a_import_office_depot_purchase_orders.php`** — pulls the ODP **850** from S3 bucket
63
+ `agilant-as2`, prefix `OfficeDepot/` (excluding `OUTBOX/` and `SENT/`; `:46` S3 read,
64
+ `:54-58` prefix filters) and creates the downstream **ODP SalesOrder** (customerId=1 = Office
65
+ Depot). **Deletes the S3 object after processing** (`:769`) so an absent S3 object is expected
66
+ once ingested, not evidence of a miss, *and* a failed import leaves nothing to retry.
67
+
68
+ > **⚠ Correction (2026-08-04): `edi/1_download_edi_s3_create_po_toga.php` is DEAD CODE.** It
69
+ > appears in **no** schedule file. The scheduled entry *named* "Download EDI From S3 Files &
70
+ > Create PO - EDI Office Depot" (`*/5 * * * *`, `worker/schedules/cron.worker.sync.json`
71
+ > `:254-259`) actually points at **`toga2/compass/workflow/3a_import_office_depot_purchase_orders.php`**.
72
+ > The two scripts have **different idempotency and different API call shapes** — `edi/1`'s create
73
+ > branch POSTs the SO with *nested* items and a `customer.name`, while **3a POSTs the header alone
74
+ > (uuids) and then POSTs the items in a separate request**. Reasoning from `edi/1` gives wrong
75
+ > answers about what production does.
76
+ >
77
+ > **General rule: never infer which script runs from its filename or folder.** Verify against
78
+ > `worker/schedules/cron.*.json` — and note the schedule entry's **name can be actively
79
+ > misleading**.
80
+
81
+ ### 3a is idempotent — re-dropping an EDI file is safe
82
+
83
+ Every step of 3a is gated by a `totalRecordCount == 0` existence check, so a re-drop produces **no
84
+ duplicates and backfills whatever is missing**:
85
+
86
+ | Step | Guard | Behavior on re-run |
87
+ |------|-------|--------------------|
88
+ | ODP SalesOrder | `:162-227` existence check | reuses the existing SO |
89
+ | SO items | `:470-498` | matches existing items **case-insensitively by `partNumber`**; creates only unmatched ones |
90
+ | ODP PurchaseOrder | `:356-436` | creates only if missing |
91
+ | `PurchaseOrders_SalesOrders` + `PurchaseOrderItems_SalesOrderItems` bridges | `:632` | creates only if missing |
92
+
93
+ **KNOWN GAP: 3a never updates the SalesOrder HEADER on an existing order.** So
94
+ `customerPurchaseOrder` stays `NULL` on a repaired order and must be set manually. See
95
+ [Recovering a lost ODP EDI import](odp-edi-import-recovery.md).
96
+
97
+ ### The EDI 850 → TOGa identifier mapping (not guessable — this is the key to tracing an ODP order)
98
+
99
+ | 850 segment | Meaning | Where it lands |
100
+ |-------------|---------|----------------|
101
+ | **`BEG`** | the **Office Depot PO number** (e.g. `41608261-1135`) | `PurchaseOrders.number`, vendor "Agilant Solutions Inc" |
102
+ | **`REF~QC`** | the **ODP SALES ORDER number** (e.g. `475390815001`) | `SalesOrders.number` with `customerId = 1` |
103
+ | **`REF~EU` / `REF~PO`** | the **Compass MITS PO number** (e.g. `50310002-1`) | its `c_mitsSalesOrder` field holds the ODP SO number |
104
+ | **`REF~LU`** | the **Compass sales order** (e.g. `SA135273`) | `SalesOrders` with `customerId = 2` |
105
+ | **`PO1`** | the line | qty, UOM, unit price, vendor part, ODP item id |
106
+
107
+ **⚠ `REF~QC` — not `BEG` — is the value to search on for the ODP SalesOrder.** Searching
108
+ `PurchaseOrders` / `SalesOrders` for the `BEG` number finds nothing for the SO and wastes time.
109
+ (This is the segment-level detail behind the "don't search by the MITS PO number" rule below.)
110
+ 4. **`workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php`** — scheduled
111
+ **every 10 minutes** (`*/10`, verified against `worker/schedules/` on 2026-08-04; this doc
112
+ previously said "hourly"); picks up ODP SalesOrders
65
113
  (`OfficeDepotSalesOrders.customerId = 1 AND c_dtTransmittedToNetsuite IS NULL`), creates the
66
114
  SO in NetSuite, and writes back `c_dtTransmittedToNetsuite` + `c_netsuiteInternalSalesOrderId`
67
115
  **onto the ODP SalesOrder (customerId=1), never the Compass SO (customerId=2)**.
@@ -70,6 +118,10 @@ ODP SalesOrder (customerId = 1)
70
118
  - **computer-kit orders** (Bundles 187–196) wait for a **2nd PO** before syncing.
71
119
  - a **30-minute delay** after the first ODP PO is created.
72
120
 
121
+ Because the selection is only `c_dtTransmittedToNetsuite IS NULL` (plus `number NOT LIKE 'MA%'`),
122
+ a **zero-item ODP SalesOrder sits in this queue retrying every 10 minutes** until its lines are
123
+ restored — the retry loop is the symptom, not the cause.
124
+
73
125
  ## Cron 5 NetSuite SO creation — failure modes & the two ODP error emails
74
126
 
75
127
  Two different Compass ODP error emails exist, from **two different pipeline stages**. They look
@@ -141,6 +193,15 @@ wrong record makes every order look "stuck." Use the **join**, never a number ma
141
193
  transmission feature.)
142
194
 
143
195
  ## Change history
196
+ - 2026-08-04 — **Corrected the 850-import step**: `edi/1_download_edi_s3_create_po_toga.php` is
197
+ **dead code in no schedule**; the schedule entry named "Download EDI From S3 Files & Create PO -
198
+ EDI Office Depot" (`*/5`) actually runs `workflow/3a_import_office_depot_purchase_orders.php`,
199
+ which has different idempotency and posts the SO header and its items as **two separate API
200
+ requests**. Added 3a's full existence-check/idempotency map (re-dropping a file is safe; header is
201
+ never updated so `customerPurchaseOrder` stays NULL), the **EDI 850 → TOGa identifier mapping**
202
+ (`BEG` = ODP PO, `REF~QC` = ODP SO — the one to search on, `REF~EU`/`REF~PO` = MITS PO,
203
+ `REF~LU` = Compass SO), and corrected cron 5's schedule to `*/10`. Production incident
204
+ investigation; no code change. (dfranks)
144
205
  - 2026-08-03 — Documented cron 5's "Please enter a value for Ext Price" root cause (`rate` set
145
206
  only for NetSuite item groups, never plain items → NetSuite `add()` USER_ERROR, order re-emails
146
207
  hourly while stuck), the two distinct ODP error emails (3a PO-import vs 5 SO-creation) and cron
@@ -5,8 +5,8 @@ project: _Underscore
5
5
  client: compass-usa
6
6
  type: workflow
7
7
  status: active
8
- updated: 2026-06-30
9
- owners: ["jcardinal", "bala"]
8
+ updated: 2026-08-04
9
+ owners: ["jcardinal", "bala", "dfranks"]
10
10
  files: []
11
11
  related:
12
12
  - clients/compass-usa/profile.md
@@ -93,6 +93,41 @@ one-upstream for bundles.
93
93
  5. **Mirror back-links:** every IF on a non-source SO (ODP/Agilant) should have
94
94
  `upstreamItemFulfillmentId` set, and each of its items `upstreamItemFulfillmentItemId` set.
95
95
 
96
+ ## Detecting "empty shell" orders left by a fatal mid-sequence
97
+
98
+ The ODP EDI import creates a SalesOrder **header** and its **line items** in **separate API
99
+ requests** (`POST /sales-orders`, then `POST /sales-order-items` ~4s later — see the
100
+ [ODP pipeline doc](odp-order-pipeline-to-netsuite.md)). If the *second* request dies, the header is
101
+ already committed and the items roll back, leaving a **zero-item sales order**. This is the
102
+ signature to sweep for whenever an api2 write fatals — and, because a fatal can leave **no API log
103
+ row at all** ([request logging](../../../2.0/apps/api2/features/request-logging.md)), the **data is
104
+ the only trail**.
105
+
106
+ Sweeps against `Client_Compass`:
107
+
108
+ 1. **Zero-item sales orders** — `SalesOrders LEFT JOIN SalesOrderItems … GROUP BY so.id HAVING
109
+ COUNT(soi.id) = 0`.
110
+ 2. **Zero-item purchase orders** — the same shape over `PurchaseOrders` / `PurchaseOrderItems`.
111
+ 3. **ODP SOs with no upstream PO** — ODP SOs (`customerId = 1`) `LEFT JOIN
112
+ PurchaseOrders_SalesOrders WHERE` the bridge row `IS NULL`.
113
+
114
+ > **⚠ LIMITATION: this finds only PARTIAL casualties.** An attempt that rolled back *completely*
115
+ > leaves no row in any table, and with no API log row there is no trail anywhere — only the error
116
+ > tracker's **per-event detail** can enumerate those. Never report an orphan sweep as a complete
117
+ > casualty list.
118
+
119
+ ### Repairing a header-only scalar field: direct SQL vs `PUT`
120
+
121
+ When only a scalar needs setting on an existing order (e.g. `customerPurchaseOrder`, which the ODP
122
+ importer never backfills), **direct SQL is the deliberate choice over `PUT /sales-orders/{uuid}`**: a
123
+ `PUT` fires the record-14 `POST`/`PUT` [payload
124
+ interceptor](../../../2.0/apps/api2/features/api-payload-interceptors.md) and drags the whole order
125
+ back through MITS/NetSuite logic for one field.
126
+
127
+ Accept the two tradeoffs: **no API log row** for the change, and **`dtUpdated` still moves** (the
128
+ column is `ON UPDATE CURRENT_TIMESTAMP`), which can look like a fresh modification to anything doing
129
+ `dtUpdated`-based change detection downstream.
130
+
96
131
  ## Verified schema corrections (older code/scripts assume these wrongly)
97
132
  - **`ItemFulfillmentPackages` does NOT exist** — header IF tracking is `ItemFulfillments_TrackingNumbers`.
98
133
  - `ItemFulfillmentItemUnits` has **no `trackingNumberId`** — unit tracking is the bridge
@@ -120,6 +155,13 @@ runtime workflow.
120
155
  - High-multiplier over-fulfillment (5×–20×) does not fit the split-PO spurious-link pattern — separate cause.
121
156
 
122
157
  ## Change history
158
+ - 2026-08-04 — Added the **"empty shell" order signature** — the ODP importer writes the SO header
159
+ and its items in two separate API requests, so a fatal on the second leaves a committed,
160
+ zero-item order — plus the three orphan sweeps that detect it (zero-item SOs, zero-item POs, ODP
161
+ SOs with no `PurchaseOrders_SalesOrders` bridge) and the limitation that they only find *partial*
162
+ casualties. Also recorded why a header-only scalar repair uses **direct SQL over `PUT`** (a `PUT`
163
+ fires the record-14 interceptor and the whole MITS/NetSuite path) and its two tradeoffs (no API
164
+ log row; `dtUpdated` still moves via `ON UPDATE CURRENT_TIMESTAMP`). (dfranks)
123
165
  - 2026-06-30 — Updated the IF-stage invariant: `itemFulfillmentStageId` is now NOT NULL and
124
166
  picked/packed/shipped stages are all in use (resolved by slug), superseding "stage 3 = Shipped;
125
167
  stages 1/2 unused". Compass order status remains shipped-only. (bala)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.509",
3
+ "version": "1.0.511",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",