toga-ai 1.0.951 → 1.0.953

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.
@@ -12,6 +12,7 @@
12
12
  | [Tools — Developers Folder (UUID & Password Generators)](features/developer-tools.md) | Tools "Developers" folder — the Generate UUID and Generate Password tools (reference example of the one-tool-per-`mvc/<route>/get.php` pattern); open for the ge |
13
13
  | [/emails Email Queue Review (Tools → 2.0 Logs_<Client>.Email)](features/email-queue-review.md) | Staff page to browse any 2.0 client's `Logs_<Client>.Email` queue and release/reject `HELD` review-hold emails. |
14
14
  | [/errors Curation Console (Tools → shared Core Logs DB)](features/errors-curation-console.md) | Internal-only 1.0 Tools triage/curation screen (`/errors`) for the 2.0 Issue/Event error pipeline, reading the shared Core Logs DB via `db_toga2logs`; curates, |
15
+ | [Etilize Refresh page (tools /etilize/refresh → worker2 Etilize actions)](features/etilize-refresh.md) | Tools page to re-pull Etilize specs/images/translations for up to 25 items of any Etilize client by chaining existing worker2 actions; open when changing the pa |
15
16
  | [Force Logout (tools /developers/force-logout → worker2 sets META_LAST_REFRESH_DATETIME)](features/force-logout.md) | Tools page that signs every 2.0 front-end user out after a big deploy by changing Core parameter `META_LAST_REFRESH_DATETIME` through a worker2 job; open to tri |
16
17
  | [GitHub Audit (monthly code-churn report)](features/github-audit.md) | Internal monthly code-churn report at `/developers/github-audit` (lines added/removed across every `agilantsolutions` repo for one month); open for the two-phas |
17
18
  | [Legacy Email Notifier (tools /email-migration/notify)](features/legacy-email-notifier.md) | SSO-gated admin tool at `/email-migration/notify` that emails employees a digest of mail still hitting their legacy domains (asks senders to switch to `@togatec |
@@ -0,0 +1,57 @@
1
+ ---
2
+ title: Etilize Refresh page (tools /etilize/refresh → worker2 Etilize actions)
3
+ framework: "1.0"
4
+ repo: tools
5
+ project: Tools
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-10-05
10
+ owners: [bala]
11
+ files:
12
+ - tools/_/app/etilize/refresh.php
13
+ - tools/mvc/etilize/refresh/get.php
14
+ - tools/mvc/etilize/refresh/post.php
15
+ - tools/_/app/nav.php
16
+ related:
17
+ - ./worker2-job-bridge.md
18
+ - ./mvc-data-access-patterns.md
19
+ - ./persona-gated-navigation.md
20
+ - ../../../2.0/apps/worker2/features/etilize-catalog-item-import.md
21
+ - ../../../2.0/apps/worker2/features/etilize-item-translation-import.md
22
+ - ../../../clients/compass-canada/features/french-item-feature-translations.md
23
+ ---
24
+
25
+ Tools page to re-pull Etilize specs/images/translations for up to 25 items of any Etilize client by chaining existing worker2 actions; open when changing the page, the step order, or the client list.
26
+
27
+ ## Summary
28
+
29
+ Developers > Etilize Refresh (`/etilize/refresh`, persona **Development Team** only). Pick a client, search items (part number or title), add up to 25 items each with an Etilize PID, tick English and/or a client language (e.g. fr-CA), press Refresh. No new worker2 code: each step is an existing worker2 action queued through the [job bridge](./worker2-job-bridge.md). Works for every client DB that has `Items.c_etilizeProductId` (37 at build time; tested on Compass Canada).
30
+
31
+ ## How it works
32
+
33
+ A run is up to 3 steps, in this order. Each later step runs only for items that did not fail an earlier one.
34
+
35
+ 1. `Etilize/Items/RefreshItemsFromEtilize(client, items[{partNumber, etilizeId}], itemCatalog=<catalog id>)` — one job per internal catalog. Saves the PID, re-pulls specs + images. The PID is validated at Etilize first, so a bad PID writes nothing.
36
+ 2. `Etilize/ItemTranslations/BackfillEtilizeAttributeIds(client, partNumbers)` — **required before translations.** Step 1 creates new `Features` without `c_etilizeAttributeId`; the translation pass matches features by that id, so without it new specs stay English.
37
+ 3. `Etilize/ItemTranslations/ImportItemsLanguageByPartNumbers(client, languageCode, languageName, partNumbers)` — one per ticked language. Reads `Items.c_etilizeProductId`, so it must run after step 1. Language jobs do not block each other.
38
+
39
+ Run state:
40
+ - The chain is advanced **server-side on each poll** (`post.php`), not by page JS.
41
+ - Run state lives in `$_SESSION`, so a closed tab resumes when the page is reopened. One unfinished run per session.
42
+ - Status per job comes from the JSON in `WorkerJobs.output`, not `isSuccess` (see Gotchas).
43
+
44
+ Client list and DB access (`App_Etilize_Refresh`):
45
+ - `Core.Clients` + `Databases` + `DatabaseHosts` for the `ENVIRONMENT` slug, kept to client DBs that have `Items.c_etilizeProductId` (checked in `information_schema`).
46
+ - Client DBs are read over the existing `[database_true]` connection (all prod `Client_` DBs share one writer host). A client whose `DatabaseHosts.hostWriter` differs from the `[database_true]` hostname is refused.
47
+ - The DB name always comes from Core and is regex-checked before use. Core and Client queries are never joined.
48
+
49
+ ## Gotchas
50
+
51
+ - **Etilize actions report failure inside a "successful" job.** They catch their own errors and return `{success:false, errors:["item <partNumber>: <msg>"], fatal?}` while `WorkerJobs.isSuccess = 1`. Read per-item failures from `errors`. General rule in [job bridge](./worker2-job-bridge.md).
52
+ - **Send only the exact worker2 parameter names.** An extra key (e.g. `partNumber` on the backfill) fails the whole job with *Unknown named parameter*. See [creating worker actions](../../../2.0/apps/worker2/features/creating-worker-actions.md).
53
+ - **JSON posted from page JS arrives HTML-escaped** and must be un-escaped before `json_decode` (see [mvc patterns](./mvc-data-access-patterns.md)).
54
+ - **Duplicate jobs from double requests** are guarded at the DB, not the session (see [job bridge](./worker2-job-bridge.md)).
55
+
56
+ ## Change history
57
+ - 2026-10-05 — Created: Etilize Refresh page chaining Refresh → Backfill → per-language translation, server-side chain on poll, session-resumable runs. (bala)
@@ -6,7 +6,7 @@ project: Tools
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-10-05
10
10
  owners: [jcardinal, bala, ajean]
11
11
  files:
12
12
  - tools/_/app/nav.php
@@ -14,6 +14,7 @@ files:
14
14
  - library/app/error.php
15
15
  - library/app/database.php
16
16
  - library/app/string.php
17
+ - library/app/framework.php
17
18
  related:
18
19
  - ./persona-gated-navigation.md
19
20
  - ./compass-user-persona-admin.md
@@ -92,6 +93,9 @@ This affects **every 1.0 app**, not just Tools.
92
93
 
93
94
  Use the project's own stripper before logging or emailing a request value: `App_String::stripBreaksAndTabs($input, $replaceWith)`. It remains a character filter, not an escaper — still escape/cast before the value reaches SQL.
94
95
 
96
+ ### Every `$_GET`/`$_POST` value is HTML-escaped on arrival — JSON posts break
97
+ `library/app/framework.php` (~line 64) runs every `$_REQUEST`/`$_GET`/`$_POST` value through `App_String::xssProtect()` (htmlspecialchars). JSON posted from page JS arrives as `{&quot;...&quot;}` and `json_decode` returns `NULL`. Undo it **once** with `App_String::xssUnprotect()` before `json_decode`, then validate the decoded values as usual.
98
+
95
99
  ### By-reference row helpers
96
100
  `buildArrayOfRows`, `fetchOne`, and `fetchRow` take `$res` **by reference**. Assign the `query()` result to a **variable first**, then pass it — passing the call result inline emits PHP "Only variables should be passed by reference", which **renders to the page** when `display_errors` is on.
97
101
 
@@ -154,3 +158,6 @@ set_error_handler(fn(): bool => true); // swallow non-fatal notices/deprecations
154
158
  - [SAML SSO auth](./saml-sso-auth.md)
155
159
  - [Tools architecture](../architecture.md)
156
160
  - [1.0 MVC page pattern & app skeleton](../../library/features/mvc-page-pattern-and-app-skeleton.md)
161
+
162
+ ## Change history
163
+ - 2026-10-05 — Added the request-input xssProtect gotcha (JSON posts decode to NULL). (bala)
@@ -6,8 +6,8 @@ project: Tools
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: [jcardinal]
9
+ updated: 2026-10-05
10
+ owners: [jcardinal, bala]
11
11
  files:
12
12
  - tools/assets/css/theme.css
13
13
  - tools/assets/js/theme.js
@@ -97,6 +97,7 @@ Two `<img>` wordmarks are in the markup and CSS shows one per theme (`.login-log
97
97
  3. **Per-file colour multiset vs. `git HEAD`.** Compare the *multiset* of resolved colour values per file, not a line-by-line diff — an index/line-based diff is useless once lines are inserted, whereas a multiset comparison is immune to line-number shifts.
98
98
 
99
99
  ## Gotchas
100
+ - **Global input rules fight compact inputs.** `style.css` sets `input[type=text] { width: 90px }` and a large padded/shadowed rule on every input except `#searchQuery` and `.stat-value`. For a compact input on a Tools page, add class `stat-value` and style it with a 2-class selector (e.g. `.my-table .stat-value`) so it beats `input[type=text]`.
100
101
  - **`assets/css/style.scss` is DEAD in this repo — edit `style.css` directly.** Many recent commits touch the `.css` while the `.scss` was last touched in an old ticket; the `.css` has drifted ~450 lines ahead. There is no sass binary and no `package.json`. "Helpfully" recompiling the SCSS would **silently revert a large amount of shipped work**.
101
102
  - **`App_FrameworkIndex::setHeaderCode()` is the only hook for injecting markup into `<head>` in 1.0** (library echoes `$this->headerCode` inside `<head>` after the CSS/JS includes). It **SETS rather than appends**, and before this feature it was unused anywhere in tools — so a **second caller silently clobbers the first**. If another feature needs head markup, refactor to compose both strings; do not add a second `setHeaderCode()` call.
102
103
  - **A colour literal used as both a foreground and a background must be split into two tokens**, or dark mode inverts it the wrong way. Real cases in tools: `#10113A` (body text *and* nav/public-header background), `#20242c` (text-strong *and* a dark chip fill), `#999` (text grey *and* a badge fill), `#eef0f4` (border *and* badge fill), `#0f172a` (design.css `--d-ink` text *and* the code-block background). Detect these **mechanically** — collect, per candidate token, the set of CSS properties it appears with (background vs. colour/border). Do not eyeball it.
@@ -119,3 +120,6 @@ Two `<img>` wordmarks are in the markup and CSS shows one per theme (`.login-log
119
120
  - [Legacy email notifier](./legacy-email-notifier.md)
120
121
  - [Design demo admin](./design-demo-admin.md)
121
122
  - [Frontend standard](../../../standards/frontend.md)
123
+
124
+ ## Change history
125
+ - 2026-10-05 — Added the global input width/padding gotcha and the `stat-value` opt-out. (bala)
@@ -6,12 +6,14 @@ project: Tools
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-01
9
+ updated: 2026-10-05
10
10
  owners: [mhammontree, bala]
11
11
  files:
12
12
  - tools/_/app/worker.php
13
13
  related:
14
14
  - ./force-logout.md
15
+ - ./etilize-refresh.md
16
+ - ../../../2.0/apps/worker2/features/creating-worker-actions.md
15
17
  - ../../../clients/compass-usa/features/people-file-import-admin-page.md
16
18
  - ./mvc-data-access-patterns.md
17
19
  - ../../../2.0/apps/worker2/architecture.md
@@ -50,6 +52,10 @@ empty result — because the wrapper format is worker2's, not a contract.
50
52
  (c) both succeeded → `ok=true`.
51
53
  A caller that tests only `workerJobId === null` reads (b) as success and then shows "still
52
54
  running" forever, because no worker will ever pick that row up. **Test `ok`, not the id.**
55
+ - **`isSuccess = 1` does not mean the work succeeded.** Actions that catch their own errors (e.g. all Etilize actions) return `{success:false, errors:[...]}` and the job is still stamped `isSuccess = 1`. Decide success from the decoded `output` JSON (`success`, `errors`, `fatal`), not the column.
56
+ - **`Core.WorkerJobs.parameters` is a MySQL JSON column.** MySQL re-orders keys and adds spaces, so `parameters = '<json string>'` never matches. Compare with `parameters = CAST('<json>' AS JSON)`. Filter on the index `WorkerJobs_scheduling_IDX (isSuccess, dtQueued, dtStarted)` first.
57
+ - **Guard duplicate enqueues in the DB, not the session.** Tools sessions use the `rediscluster` handler with no session locking, so two concurrent requests both read and write `$_SESSION`. Before enqueuing, reuse a pending identical row (same action + `CAST` parameters, last 2 minutes).
58
+ - **Send only the action's exact parameter names** — the dispatcher spreads them as named args; one extra key fails the job (*Unknown named parameter*). See [creating worker actions](../../../2.0/apps/worker2/features/creating-worker-actions.md).
53
59
  - **`isSuccess IS NULL` means two different things** — running, or abandoned. Same trap as
54
60
  [stranded worker jobs](../../../2.0/apps/worker2/features/stranded-worker-jobs.md).
55
61
  - **Tools must not speak SFTP (or any integration worker2 owns).** Decided on CTO review
@@ -75,5 +81,6 @@ empty result — because the wrapper format is worker2's, not a contract.
75
81
  4. Dev-bypass login must use a `Client_True` email (e.g. `@togatech.com`, not `@goagilant.com`).
76
82
 
77
83
  ## Change history
84
+ - 2026-10-05 — Added: isSuccess=1 can hide a failed action, JSON-column parameters compare, DB-level duplicate-enqueue guard (Etilize Refresh page). (bala)
78
85
  - 2026-10-01 — Added the local tools→worker2 test setup and the prod-only `[worker]` config fact (TRUE-82414). (bala)
79
86
  - 2026-09-23 — Created from TRUE-82250: documented the three `enqueue()` outcomes, the log-line-wrapped `output`, and the CTO decision that tools never speaks SFTP (phpseclib 1.x sha1-only KEX + credential duplication). (mhammontree)
@@ -6,8 +6,8 @@ project: TOGa Blox
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: [apeterson]
9
+ updated: 2026-10-05
10
+ owners: [apeterson, tcox]
11
11
  files:
12
12
  - toga-blox/package.json
13
13
  - toga-blox/src/components/index.ts
@@ -29,7 +29,7 @@ Shared React+TS component/template/API library (`@agilant/toga-blox`) reused by
29
29
 
30
30
  `toga-blox` (npm package **`@agilant/toga-blox`**) is the shared React + TypeScript component / template / utility library reused across the 2.0 frontends: `toga2-view`, `toga2-supply`, `toga25-supply`, `toga2-commerce`, `toga2-hub`. Not a PHP framework repo — no `_underscore` backend. Filed under `2.0/` because every consumer is a 2.0 app, but structurally a standalone front-end package (`dependsOn: []`).
31
31
 
32
- **New-platform apps (toga25-desk, toga25-iq, toga2-template, future apps) do not use this repo — they use [toga25-blox](../toga25-blox/architecture.md), a source-shared (git, not npm) replacement.**
32
+ **Legacy — fixes only.** No new components and no Storybook work here. [toga25-blox](../toga25-blox/architecture.md) (`@toga/blox`, source-shared) replaces this library for **all** apps, eventually: new platform apps first, then these consumers. When an app moves, rewrite onto toga25-blox pieces — do not port this code wholesale. New shared UI goes to toga25-blox.
33
33
 
34
34
  Ships: presentational + data-aware **components** (`src/components/`), **templates** wiring components into ready screens (`src/templates/`, e.g. `PrimaryTable`, `Login`, `AddNotes`), a thin **API client** over axios + React Query (`src/api/`, `src/reactQuery/`), and **utilities** (`src/utils/`). Builds with `tsc` to `dist/` (not bundled), develops via Storybook, tests with Vitest.
35
35
 
@@ -6,7 +6,7 @@ project: TOGa Blox
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-10-05
10
10
  owners: [apeterson, tcox]
11
11
  files:
12
12
  - toga-blox/src/components/BaseButton
@@ -74,6 +74,21 @@ Defect 4 is about swapping variants; this is the shape of the same problem for a
74
74
  - **Consumer symptom.** One page toolbar renders its buttons at three different heights, and the developer cannot fix it from `themeConfig.json` — the number they need is not exposed. Nothing in the theme layer hints geometry is off-limits, so time is lost hunting the app's own CSS.
75
75
  - **Fix direction.** Same as defect 5: convert both to `var(--baseButton-height, <existing literal>)` (and give `.primaryBtn` the same base height), non-breaking because the fallback preserves today's look. Until then a consumer must own the chrome: `toga25-supply` built its own `ToolbarButton` rather than retheme tokens shared by ~all buttons in the app — see [toolbar button chrome](../../toga25-supply/features/toolbar-button-chrome.md).
76
76
 
77
+ ### Tooling defects (found 2026-09-28, Storybook/Docker audit)
78
+
79
+ **7. Storybook addons in `dependencies` — they install into every consumer**
80
+ - **Mechanism.** `@storybook/addon-docs`, `@storybook/addon-viewport`, `storybook-addon-react-router-v6` sit in `dependencies`, not `devDependencies`.
81
+ - **Consumer symptom.** Every app installing `@agilant/toga-blox` pulls Storybook packages it never uses (install weight, peer noise).
82
+ - **Fix direction.** Move them to `devDependencies`. Non-breaking.
83
+
84
+ **8. Storybook (7.6.24) is local-only and renders unstyled**
85
+ - Never deployed. Defines no host tokens (frontend §13e), so components render unstyled. Many exported components and all 3 templates have no story. `addon-onboarding` is configured but not installed.
86
+ - **Fix direction.** None planned — Storybook investment goes to [toga25-blox](../../toga25-blox/architecture.md), not here. To verify a CSS change, see the Storybook gotcha in [table](table.md).
87
+
88
+ **9. Docker files call scripts that do not exist**
89
+ - `docker-compose` runs `npm run storybook` (the script is `npm run blox`); the `Dockerfile` `CMD` runs `npm run start` (no such script). Neither container works as written.
90
+ - **Fix direction.** Point them at real scripts, or delete them.
91
+
77
92
  ### Already-known authoring items (carried over from §22)
78
93
 
79
94
  Long-standing, previously recorded only as consumer-side advice:
@@ -89,6 +104,9 @@ Long-standing, previously recorded only as consumer-side advice:
89
104
  - **Do not "fix" these in an app.** Working around them in a consumer is §22's job and is legitimate; changing app code to *imitate* the defect is not.
90
105
  - **A fix here is a fleet-wide change.** blox is unbundled and consumed by ~40 call sites; anything touching auth or `localStorage` keys changes live session behavior on the next channel head a consumer picks up — and `toga2-commerce` picks up the channel head **automatically at build time**, with no PR. Sequence auth fixes deliberately.
91
106
 
107
+ ## Change history
108
+ - 2026-10-05 — added tooling defects 7–9 (Storybook addons in `dependencies`, unstyled local-only Storybook, broken Docker scripts) (tcox)
109
+
92
110
  ## Related
93
111
 
94
112
  - [frontend standard §22](../../../standards/frontend.md) — the app-side workarounds this backlog is the counterpart to.
@@ -6,8 +6,8 @@ project: TOGa Blox
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-23
10
- owners: [apeterson]
9
+ updated: 2026-10-05
10
+ owners: [apeterson, tcox]
11
11
  files:
12
12
  - toga-blox/src/components/EnvironmentBadge/EnvironmentBadge.tsx
13
13
  - toga-blox/src/components/EnvironmentBadge/EnvironmentBadge.css
@@ -64,7 +64,9 @@ Classifies `VITE_API` into `production` | `local` | `unknown` (plus the existing
64
64
  - **A CSS class typo silently unstyles the badge.** `.env-localost` (missing `h`) shipped for a long time; the localhost badge rendered as plain text and nobody noticed. Class names must match the endpoint strings the classifier returns — add both together.
65
65
  - **Testing `import.meta.env`:** use `vi.stubEnv("MODE", …)` / `vi.stubEnv("VITE_API", …)`. To set the hostname without navigating jsdom, redefine `window.location` with `Object.defineProperty`.
66
66
  - **Consumers import it in ways that matter.** A barrel `import { EnvironmentBadge } from "@agilant/toga-blox"` drags in unrelated peers at build time; older published versions exported it as **default only**. See [toga2-commerce Amplify build & deploy](../../toga2-commerce/workflows/amplify-build-and-deploy.md).
67
+ - **toga2-supply does NOT use this badge.** It mounts its own copy (`toga2-supply/src/components/layout/Header/EnvironmentBadge.tsx` + `src/utils/getEndpointFromEnvironment.ts`), written because older blox mis-labelled unknown API hosts. A change to the hide rule or classifier here does not reach toga2-supply — change both, or move toga2-supply back to blox's badge.
67
68
  - Do not remove the badge from an app to "clean up" — it is a deliberate QA tool.
68
69
 
69
70
  ## Change history
71
+ - 2026-10-05 — noted toga2-supply's app-local copy of the badge + classifier (tcox)
70
72
  - 2026-09-23 — Hostname now overrides the hide rule (`isNonProductionHost`); endpoint classifier matches `production` explicitly and adds `local`/`unknown`; fixed `.env-localost` typo, added `.env-local`/`.env-unknown`; 24 tests added (apeterson)
@@ -6,7 +6,7 @@ project: TOGa Blox
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-28
9
+ updated: 2026-10-05
10
10
  owners: [apeterson, tcox, bala]
11
11
  files:
12
12
  - toga-blox/src/components/Table/hooks/useFetchTablePageMeta.ts
@@ -121,7 +121,7 @@ This exists because an unscoped `GET /{recordRoute}` returns *every* row of a lo
121
121
  - **A filter popover's "Clear" must call `onClear()` — resetting local state only leaves the table filtered.** `HeaderFilterMultiselect.handleClear` did just `setSelectedOptions([])`, so the URL param (owned by the consuming app) and the applied filter survived: the popover showed nothing selected while the table stayed filtered. Fixed 2026-09-28 — it now calls `onClear()` and closes, **guarded by `if (filterValue)`**. The guard matters: `onClear` routes through `PrimaryTableHeaderCell` → `table.options.onColumnFiltersChange` → toga25-supply `useServerTableUrlState`, which resets to **page 1 on any filter change** — so an unguarded Clear with nothing applied would bounce the user off their page.
122
122
  - **⚠ OPEN — `HeaderFilterRange.handleClear` has the same bug** (~line 162): it resets `singleValue`/`minValue`/`maxValue` but never calls `onClear()` and never closes the popover. Same fix shape as the multi-select. Not fixed as of 2026-09-28.
123
123
  - **⚠ OPEN — `SortIcon` has no `aria-*` at all.** Sort direction and position are conveyed by shape only — no `aria-label`, no `aria-sort`. Pre-existing; needs its own ticket.
124
- - **A unit test cannot prove a CSS change — verify in blox's own Storybook.** JSDOM applies no real layout, so a Vitest test can only assert the modifier class is present; it can never show the triangle actually flipped or that the digit stayed upright. Add a co-located `*.stories.tsx` rendering the states side by side, run `npm run blox` (port 6006), and look at it.
124
+ - **A unit test cannot prove a CSS change — verify in blox's own Storybook.** JSDOM applies no real layout, so a Vitest test can only assert the modifier class is present; it can never show the triangle actually flipped or that the digit stayed upright. Add a co-located `*.stories.tsx` rendering the states side by side, run `npm run blox` (port 6006), and look at it. **⚠ blox's Storybook defines no host tokens** (frontend §13e), so components render unstyled there — set the `--*` tokens the component reads in the story/decorator, or check the change in a consuming app.
125
125
  - **⚠ OPEN — the multi-select filter matches by SUBSTRING, not exactly.** `HeaderFilterMultiselect` comma-joins the picked slugs and `src/api/tableData/getDataTableData.ts` turns that into `where: { or: [ { field: { contains: value } }, … ] }`. Picking "Jon Smith" also matches "Jon Smithson" — it over-includes rows.
126
126
  - **⚠ OPEN — an option value containing a comma silently returns zero rows.** It splits into two bogus filter terms, with no error. Both bugs are pre-existing and affect **every** multi-select column including Order Status; both fix in one place — send a list / exact-equals operator instead of a comma-joined `contains`. Not fixed as of 2026-09-15.
127
127
  - **⚠ `useTableData` APPENDS `.uuid` to `additionalData.slug` — unless the slug is on a hardcoded allow-list.** The list is exactly `catalogId`, `customerId`, `Items.catalogId`, `SalesOrderItems.uuid`, `SalesOrders.uuid` (verified in `@agilant/toga-blox` **1.1.2-production.144**, the version toga25-supply pins). Consequences for anyone writing a filter slug into config or a `TableLevel` callback:
@@ -144,6 +144,7 @@ This exists because an unscoped `GET /{recordRoute}` returns *every* row of a lo
144
144
  - **Table/surface meta is cached with `Infinity` staleTime/gcTime** (`useFetchTablePageMeta`), so after a DB metadata change (`TableViewFields`, `Core.RecordFields`) a **hard reload** is required to see it. And the app consumes toga-blox's built **`dist/`**, not `src/` — editing blox source requires `npm run build` before it takes effect at runtime.
145
145
 
146
146
  ## Change history
147
+ - 2026-10-05 — Storybook verify gotcha: blox's Storybook has no host tokens, components render unstyled (tcox)
147
148
  - 2026-09-28 — multi-select filter "Clear" now actually clears (calls `onClear`, page-1 guard); sort badge now points down for `desc` via CSS rotation (apeterson)
148
149
 
149
150
  ## Related
@@ -2,4 +2,4 @@
2
2
 
3
3
  | Doc | Summary |
4
4
  |-----|---------|
5
- | [TOGa Blox 2.5 — Architecture](architecture.md) | Shared React+TS SOURCE library for the new platform apps (toga25-desk, toga25-iq, toga2-template, future apps); read the ADR for full detail, not this stub. |
5
+ | [TOGa Blox 2.5 — Architecture](architecture.md) | Shared React+TS SOURCE library for the new platform apps (new platform apps first; replaces toga-blox for all apps over time); read the ADR for full detail, not |
@@ -6,27 +6,23 @@ project: TOGa Blox 2.5
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-09-26
10
- owners: [jcardinal]
9
+ updated: 2026-10-05
10
+ owners: [jcardinal, tcox]
11
11
  files:
12
12
  - toga25-blox/src
13
- related: []
13
+ related:
14
+ - ../toga-blox/architecture.md
14
15
  ---
15
- Shared React+TS SOURCE library for the new platform apps (toga25-desk, toga25-iq, toga2-template, future apps); read the ADR for full detail, not this stub.
16
+ Shared React+TS SOURCE library for the new platform apps (new platform apps first; replaces toga-blox for all apps over time); read the ADR for full detail, not this stub.
16
17
 
17
18
  ## Summary
18
19
 
19
- `toga25-blox` is a fresh, **source-shared** (git, not npm) React+TS library that replaces
20
- `toga-blox` for **new-platform apps only** (toga25-desk, toga25-iq, toga2-template, future
21
- apps). Apps import blox **source** from a sibling clone — `@toga/blox/<entry>` resolves via
22
- Vite alias to `../toga25-blox/src/<entry>/index.ts`. No build, no publish, no npm version.
23
- Deploy clones `toga25-blox` on the app's own branch at build time. Old npm `toga-blox` stays
24
- on `toga25-supply` / `toga2-commerce` / `toga2-view` / `toga2-hub` — do not port those apps to
25
- this repo. Full decision + rejected alternatives + consequences: `docs/decisions/0001-platform-frontend-architecture.md` in the toga25-blox repo — read that for anything beyond these rules.
20
+ `toga25-blox` is a fresh, **source-shared** (git, not npm) React+TS library that replaces `toga-blox` for **all apps, eventually** (decided 2026-09-30). New platform apps (toga25-desk, toga25-iq, toga2-template, future apps) come first. Old apps (toga25-supply, toga2-supply, toga2-commerce, toga2-view, toga2-hub, BDR, talos, togatech) move over later. Apps import blox **source** from a sibling clone — `@toga/blox/<entry>` resolves via Vite alias to `../toga25-blox/src/<entry>/index.ts`. No build, no publish, no npm version. Deploy clones `toga25-blox` on the app's own branch at build time. Old `@agilant/toga-blox` is fixes-only until each app moves. Full decision + rejected alternatives + consequences: `docs/decisions/0001-platform-frontend-architecture.md` in the toga25-blox repo — read that for anything beyond these rules.
26
21
 
27
22
  **Critical rules:**
28
23
  - **Reuse order:** an existing `toga25-blox` piece → a piece already built in another
29
24
  new-platform app (promote it to blox) → only then build new. Never duplicate.
25
+ - **Moving an old app: rewrite onto toga25-blox pieces — never port old blox code wholesale.** Old blox has 3 table engines and 3 select stacks; copying them carries that over.
30
26
  - **Import boundary:** apps may import only `@toga/blox/<entry>` (the public entry barrel) —
31
27
  lint blocks deeper paths. Blox never imports app code.
32
28
  - **Layer order (nothing imports upward):** `lib` < `core` < `tenancy` < `theme` < `surface`
@@ -44,4 +40,5 @@ this repo. Full decision + rejected alternatives + consequences: `docs/decisions
44
40
  ## Related
45
41
 
46
42
  - ADR: `docs/decisions/0001-platform-frontend-architecture.md` (toga25-blox repo).
43
+ - Component viewer: Storybook (`npm run storybook`, port 6006). Setup and open questions are in `docs/storybook.md` in the toga25-blox repo (prototype on branch TRUE-82044, awaiting Jeff's review).
47
44
  - Old npm lib for legacy 2.0 apps: [toga-blox architecture](../toga-blox/architecture.md).
@@ -6,13 +6,14 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-10-05
10
10
  owners: [bala]
11
11
  files:
12
12
  - worker2/Worker/Etilize/Items.php
13
13
  related:
14
14
  - ./creating-worker-actions.md
15
15
  - ./etilize-item-translation-import.md
16
+ - ../../../1.0/apps/tools/features/etilize-refresh.md
16
17
  ---
17
18
 
18
19
  Client-generic catalog onboarding: ingest an item catalog from an S3 CSV and enrich each item from the Etilize datasheet, writing directly to `DB_CLIENT`; open when importing/refreshing catalog items or debugging CSV mapping.
@@ -29,7 +30,7 @@ Routes are `Etilize/Items/<Method>` (see [creating-worker-actions](./creating-wo
29
30
 
30
31
  1. `ImportItemsFromCsvFile(string $client, string $s3Url, string $catalogUuid)` — **normal import**: one `ItemPrice` + `VendorItemCost` per item with **NULL currency**, using the Cost / Lease Price columns (e.g. Compass).
31
32
  2. `ImportItemsWithCurrencyPricesFromCsvFile(string $client, string $s3Url, string $catalogUuid)` — **per-currency import**: each currency-coded column becomes its own `ItemPrice` + `VendorItemCost` (e.g. Quad).
32
- 3. `RefreshItemsFromEtilize(string $client, array $items, ?string $catalog = null)` where `items = [{partNumber, etilizeId}]` — looks each item up by `partNumber` and, only if its stored `c_etilizeProductId` differs from the supplied `etilizeId`, updates it and re-pulls the item's features + images from the Etilize datasheet. Returns a per-item changed/unchanged summary.
33
+ 3. `RefreshItemsFromEtilize(string $client, array $items, ?string $catalog = null, ?string $itemCatalog = null)` where `items = [{partNumber, etilizeId}]`. `catalog` is the **Etilize** catalog (default `na`); `itemCatalog` is the **internal** catalog (id or name) that scopes the part-number lookup when a part number exists in several catalogs. Returns a per-item changed/unchanged summary. Run from the Tools [Etilize Refresh page](../../../1.0/apps/tools/features/etilize-refresh.md).
33
34
 
34
35
  ### Import flow
35
36
 
@@ -54,11 +55,14 @@ Both import actions delegate to the shared private `runItemCsvImport(..., bool $
54
55
 
55
56
  ### Etilize re-pull (`RefreshItemsFromEtilize`)
56
57
 
57
- - Item located by `partNumber`. The Etilize id is updated **only when** the supplied `etilizeId` differs from the stored `c_etilizeProductId`.
58
+ - Item located by `partNumber` (scoped by `itemCatalog` if given). The PID is **validated at Etilize first** (`getProduct` must return a product), so a bad PID writes nothing and never overwrites a working one.
59
+ - `c_etilizeProductId` is updated only when it differs; features + images are **re-pulled every run**.
60
+ - New features are created **without `c_etilizeAttributeId`** — run `ItemTranslations/BackfillEtilizeAttributeIds` before any translation import, or the new specs stay English ([translation import](./etilize-item-translation-import.md)).
58
61
  - The feature/image re-pull is **find-or-create** and **does not delete stale features** — intentional.
59
62
 
60
63
  ## Gotchas
61
64
 
65
+ - **Failures come back inside `isSuccess = 1`.** All actions catch their own errors and return `{success:false, errors:["item <partNumber>: <msg>"], fatal?}`; the job is still stamped success. Callers must read the `output` JSON.
62
66
  - CSV layout is fixed at the top: header row, a **skipped** description row, then data from row 3. Feeding a two-row (header+data) file would treat the first data row as the description row.
63
67
  - Columns are matched by header regex, not position — a renamed/missing header silently drops that column rather than mis-mapping by index.
64
68
  - Currency columns not present in the client's `Currencies` table are ignored, so a catalog that ships an unconfigured currency simply won't price it.
@@ -67,3 +71,6 @@ Both import actions delegate to the shared private `runItemCsvImport(..., bool $
67
71
 
68
72
  - [Creating worker actions](./creating-worker-actions.md) — the action class/method conventions.
69
73
  - [Etilize item translation import](./etilize-item-translation-import.md) — non-English text sidecar import for the same items.
74
+
75
+ ## Change history
76
+ - 2026-10-05 — Corrected the RefreshItemsFromEtilize signature (`itemCatalog`), PID pre-validation, re-pull every run, and the failure-in-isSuccess=1 contract. (bala)
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-10-05
10
10
  owners: [bala]
11
11
  files:
12
12
  - worker2/Worker/Etilize/ItemTranslations.php
@@ -33,7 +33,7 @@ Routes are `Etilize/ItemTranslations/<Method>` (see [creating-worker-actions](./
33
33
 
34
34
  1. `ImportAllItemsForLanguage(string $client, string $languageCode, ?string $languageName = null, ?string $catalog = null)` — translates **all** items that have a `c_etilizeProductId`.
35
35
  2. `ImportItemsLanguageByPartNumbers(string $client, string $languageCode, array $partNumbers, ?string $languageName = null, ?string $catalog = null)` — translates only the given part numbers (uses a `partNumber IN (...)` filter).
36
- 3. `BackfillEtilizeAttributeIds(...)` — populates `c_etilizeAttributeId` (on `Features`) and `c_etilizeAttributeGroupId` (on `ItemCategoryFeatureGroups`) from Etilize `en_us`, so features and groups can be matched to their Etilize attribute id. Must run **before** feature-translation import so each French value lands on the right feature/group.
36
+ 3. `BackfillEtilizeAttributeIds(string $client, ?array $partNumbers = null)` — populates `c_etilizeAttributeId` (on `Features`) and `c_etilizeAttributeGroupId` (on `ItemCategoryFeatureGroups`) from Etilize `en_us`, so features and groups can be matched to their Etilize attribute id. Must run **before** feature-translation import so each French value lands on the right feature/group — including after every `RefreshItemsFromEtilize`, which creates new features without the id.
37
37
 
38
38
  ### Import flow
39
39
 
@@ -70,3 +70,6 @@ Beyond `ItemTranslations` (Item title/description/longDescription), `importItemL
70
70
  - [Etilize catalog item import](./etilize-catalog-item-import.md) — the English catalog import these translations sit beside.
71
71
  - [Language Translation Layer](../../api2/features/language-translation-layer.md) — read-side metadata wiring.
72
72
  - [Compass Canada French item-feature translations](../../../clients/compass-canada/features/french-item-feature-translations.md) — the client use case.
73
+
74
+ ## Change history
75
+ - 2026-10-05 — Recorded the BackfillEtilizeAttributeIds signature and that it must re-run after every English refresh. (bala)
@@ -5,8 +5,8 @@ project: _Underscore
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-10-01
9
- owners: [jcardinal, apeterson]
8
+ updated: 2026-10-05
9
+ owners: [jcardinal, apeterson, tcox]
10
10
  files: []
11
11
  related:
12
12
  - ../apps/toga25-supply/architecture.md
@@ -479,6 +479,8 @@ so restoration cannot resurrect a cleared cache.
479
479
  `@agilant/toga-blox` is the front-end shared core — the React analog of `_underscore` / `library`
480
480
  on the back end. A consuming app **MUST** meet every requirement below.
481
481
 
482
+ `@agilant/toga-blox` is **legacy — fixes only**. Build new shared components in `toga25-blox` (`@toga/blox`), which replaces it for all apps over time. See [toga25-blox architecture](../apps/toga25-blox/architecture.md). These rules apply until an app has moved.
483
+
482
484
  **(a) Pin an exact published version — never a caret.** Some published versions ship an **empty
483
485
  `dist`** and break the build; a caret can float you onto one. Both apps pin exact versions. Also
484
486
  audit for **pin drift** between branches. A caret on a **prerelease** is inert (npm matches only the
@@ -637,6 +639,7 @@ The payoff: copy and icons change as a **data edit**, with no front-end deploy.
637
639
  - **Build a new generic capability app-LOCAL first; promote into blox only after a 2nd consumer
638
640
  validates the contract.** Hardening a shared contract from a single call site (n=1) bakes in that
639
641
  app's accidents.
642
+ - **Promote into `toga25-blox`, not `@agilant/toga-blox`** (legacy, fixes only). toga25-blox has its own reuse order — see its architecture doc.
640
643
  - **Mark intent with a per-module header** so a future reader knows which side a module belongs on:
641
644
 
642
645
  ```ts
@@ -15,7 +15,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
15
15
  - **test** (Test) — 16 doc(s) → [1.0/apps/test/INDEX.md](1.0/apps/test/INDEX.md)
16
16
  - **toga** (TOGa) — 3 doc(s) → [1.0/apps/toga/INDEX.md](1.0/apps/toga/INDEX.md)
17
17
  - **store** (Store) — 3 doc(s) → [1.0/apps/store/INDEX.md](1.0/apps/store/INDEX.md)
18
- - **tools** (Tools) — 24 doc(s) → [1.0/apps/tools/INDEX.md](1.0/apps/tools/INDEX.md)
18
+ - **tools** (Tools) — 25 doc(s) → [1.0/apps/tools/INDEX.md](1.0/apps/tools/INDEX.md)
19
19
 
20
20
  ## 2.0 framework
21
21
 
@@ -16,6 +16,7 @@ files:
16
16
  - dbchanges2/Client_Nychh/2026-09-30a - TransferOrderPackedAssetTagException.sql
17
17
  - dbchanges2/Client_Nychh/2026-09-15b - TransferOrderEmailGoLiveRecipient.sql
18
18
  - dbchanges2/Core/2026-09-16a - NychhTransferOrderEmailsCron.sql
19
+ - dbchanges2/Client_Nychh/2026-10-05a - TransferOrderShippedEmailOnTrackingCutover.sql
19
20
  related:
20
21
  - ./transfer-order-netsuite-push.md
21
22
  - ../../../2.0/apps/_underscore/features/units-for-items-for-purchase-orders.md
@@ -42,7 +43,7 @@ holds the two queued entry points, the backstop cron, every query and the HTML b
42
43
  `_underscore` per-client models only declare the `c_` marker fields and queue the job — they build
43
44
  nothing. That is deliberate: app-level over core, and a slow send must not sit inside an api2 save.
44
45
 
45
- Three rules carry most of the risk:
46
+ Rules that carry most of the risk:
46
47
 
47
48
  1. **Submitted fires on `postPut`, not `postPost`** — on the POST the order still holds the
48
49
  `TA…` autoincrement placeholder, not the NetSuite number.
@@ -51,6 +52,9 @@ Three rules carry most of the risk:
51
52
  3. **A failed send throws — it is never swallowed**, so nothing is ever marked sent that did not go.
52
53
  4. **Packed (and shipped) are HELD while a line is short of asset tags** — ops is warned instead, and
53
54
  the customer email sends itself once the tags land. See below.
55
+ 5. **Shipped fires on the first TRACKING NUMBER, never on the NetSuite shipped stage** (since
56
+ 2026-10-05). The warehouse often marks a fulfillment shipped only after delivery, so the stage is
57
+ unreliable. No tracking → no Shipped email, even at stage shipped. See below.
54
58
 
55
59
  Recipient is `DevTeam@togatech.com` while testing. `2026-09-15b` swaps it to
56
60
  `EndUserDeviceLifecycle@nychhc.org` — run that file only after sign-off.
@@ -61,7 +65,10 @@ Recipient is `DevTeam@togatech.com` while testing. `2026-09-15b` swaps it to
61
65
  - `SendSubmitted(string $transferOrderUuid)` — queued from the TransferOrder `postPut`.
62
66
  - `SendFulfillment(string $itemFulfillmentUuid)` — queued from the ItemFulfillment hooks.
63
67
  - `SendPending(bool $dryRun = true)` — the `*/5` backstop cron.
64
- - `resolveStagesStillToAnnounce()` — which stages are still owed (see the late-shipped case).
68
+ - `resolveStagesStillToAnnounce(stageName, stageAlreadyEmailed, hasTrackingNumber)` — which
69
+ stages are still owed (see *Shipped = tracking number*).
70
+ - `buildHasTrackingNumberSql()` — the one "has tracking" test, shared by
71
+ `readItemFulfillmentForEmail()` (`hasTrackingNumber` column) and the `SendPending` sweep.
65
72
  - `readLinesMissingAssetTags()` / `sendPackedExceptionEmail()` — the asset-tag hold.
66
73
  - `initialize()` registers the client DB before any query runs.
67
74
  - Builders: `buildItemRowsHtml()`, `buildTrackingSectionHtml()`, `buildAssetTagTableHtml()`,
@@ -122,13 +129,11 @@ they must stay in step: the Submitted guard, the order read, and both `SendPendi
122
129
  2. **Packed / Shipped** — the ItemFulfillment `postPost` / `postPut` queue `SendFulfillment` with
123
130
  the fulfillment uuid. The stage reached is recorded on `c_transferOrderEmailStageSent`, with
124
131
  `c_dtTransferOrderEmailSent` as the timestamp, so a fulfillment cannot re-announce a stage.
125
- `resolveStagesStillToAnnounce()` decides what is owed: normally the one stage reached, but when a
126
- fulfillment **arrives already shipped** and packed was never announced it returns
127
- **`[packed, shipped]`** and both send in that order in one run. Before this the packed stage was
128
- skipped outright — and **the asset-tag check lives on packed**, so a late shipped fulfillment
129
- bypassed it completely. Real case: transfer order **291773** got Submitted and Shipped, never
130
- Packed. The NYCHH NetSuite sync is a **daily** cron, so a fulfillment landing fully shipped is
131
- normal, not an edge case — see
132
+ `resolveStagesStillToAnnounce()` decides what is owed (table in *Shipped = tracking number*).
133
+ When packed was never announced and shipped is now owed it returns **`[packed, shipped]`**, both
134
+ sent in that order in one run — the **asset-tag check lives on packed**, so packed must never be
135
+ skipped (transfer order **291773** once got Submitted + Shipped, no Packed). The NYCHH NetSuite
136
+ sync is a **daily** cron, so a fulfillment landing fully shipped/tracked is normal — see
132
137
  [the per-client sync](../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md).
133
138
  3. The **backstop cron** sweeps anything the hooks missed (below).
134
139
  4. All three paths share the same markers, so nothing sends twice.
@@ -152,6 +157,48 @@ PUT follows.** Fulfillment **2704** in production: header row at 20:33:09, lines
152
157
  seconds ago with no lines yet is left for the next run instead of being mailed empty.
153
158
  - It also picks up any send that threw (the marker was never written).
154
159
  - Caps at **25 of each kind per run** (`MAXIMUM_EMAILS_PER_RUN`).
160
+ - **It is the real sender of Shipped** — see below.
161
+
162
+ ## ⚠ Shipped = tracking number, not the NetSuite stage (2026-10-05)
163
+
164
+ "Has tracking" = `ItemFulfillments_TrackingNumbers` **INNER JOIN** `TrackingNumbers` on
165
+ `trackingNumberId` with `TRIM(number) <> ''` — a bridge row can hold only a
166
+ `returnTrackingNumberId`. Built once in `buildHasTrackingNumberSql()`; never re-type it.
167
+
168
+ `resolveStagesStillToAnnounce()`, first match wins:
169
+
170
+ | Marker (`c_transferOrderEmailStageSent`) | Tracking | Stage | Owed |
171
+ |---|---|---|---|
172
+ | `shipped` | any | any | nothing |
173
+ | `packed` | yes | any | `[shipped]` |
174
+ | empty | yes | any (incl. picked — tracking means boxed) | `[packed, shipped]` |
175
+ | empty | no | packed / shipped | `[packed]` |
176
+ | else | | | nothing |
177
+
178
+ The asset-tag hold runs whenever packed is owed and blocks both.
179
+
180
+ - **⚠ `SendPending` WHERE must match that table:** marker NULL or `<> 'shipped'` AND (has tracking OR
181
+ (stage IN packed/shipped AND `COALESCE(marker,'') = ''`)). Drift → rows picked then skipped, eating
182
+ `LIMIT 25`.
183
+ - **Why the cron, not the hook, sends Shipped:** library `syncItemFulfillmentFromNetsuite`
184
+ (`library/app/api/toga2.php`) PUTs the stage first (~L7270), then units/asset tags, and writes
185
+ package tracking **last** (~L8046). The ItemFulfillment `postPut` job runs before tracking exists.
186
+ (Also removes the old race where Shipped read stale tracking/tags.) Tracking imports at picked,
187
+ packed or shipped alike.
188
+ - **Once per fulfillment.** Business rule = 1 tracking number per fulfillment; prod has violations
189
+ (IF 329047 has 2; IFs 329944/330013 share 1). Still sends once — the marker gates it.
190
+ - **Lag:** adding tracking in NetSuite bumps the IF `lastmodifieddate`, so the sync re-reads it, but
191
+ only on the NYCHH sync interval (daily). E.g. IF 330377 (NS 7566823) still Packed (status B) in
192
+ NetSuite, tracking imported 2026-10-05 11:56.
193
+ - **Only NYCHH emails off the NetSuite shipped stage** (checked 2026-10-05): Compass in-transit emails
194
+ are per tracking number from ASN data; Prudential "order shipped" is a transmission; Elite moves to
195
+ shipped when tracking lands.
196
+ - **Cutover:** `Client_Nychh/2026-10-05a` stamps `c_transferOrderEmailStageSent='shipped'` on supply
197
+ fulfillments that already had tracking (`c_dtTransferOrderEmailSent` untouched — no email sent), so
198
+ old fulfillments got no late Shipped. Re-runnable; matched 2 on prod (IF 330377/TO 291765, IF
199
+ 330400/TO 291965). **⚠ Deploy order:** the old code re-sends Packed for a row stamped `shipped`
200
+ whose stage is packed, so: pause `Core.CronJobs` id 78 (`isActive=0`) → run SQL → deploy worker2 →
201
+ re-enable id 78.
155
202
 
156
203
  ## ⚠ Missing asset tags HOLD the packed email (and warn ops instead)
157
204
 
@@ -257,7 +304,8 @@ Cost of the split, stated so nobody is surprised later:
257
304
  **1,203** have one number and **292** have two or more, **up to 25**.
258
305
  - **Many shipped fulfillments have no tracking at all** — 1,484 of 2,479 measured 2026-09-17, and
259
306
  322 of the rest have no carrier on the number. `buildTrackingSectionHtml()` drops the whole block
260
- when there is no number and drops the button when there is no URL. Placeholders are
307
+ when there is no number and drops the button when there is no URL (since 2026-10-05 Shipped needs a
308
+ number, so this now matters only for the Packed email). Placeholders are
261
309
  **`{trackingIntroSuffix}`** and **`{trackingSection}`**.
262
310
  - **Gmail clips a message past about 102,400 bytes.** `MAXIMUM_EMAIL_BODY_BYTES = 80000` budgets the
263
311
  **item block only**; the remainder is summarised as *"and N more"*. 80,000 (not the earlier
@@ -348,6 +396,11 @@ loaded out of the dbchanges2 file so an unresolved `{placeholder}` fails a test.
348
396
  unescaped values and both detail-box defects were all found there, not in production.
349
397
 
350
398
  ## Change history
399
+ - 2026-10-05 — **Shipped now fires on the first tracking number, not the NetSuite shipped stage**
400
+ (warehouse marks shipped after delivery). Shared `buildHasTrackingNumberSql()`, new
401
+ `resolveStagesStillToAnnounce()` table, matching `SendPending` WHERE; removed dead
402
+ `resolveFulfillmentEmailTemplateUuid()`. Cutover SQL `2026-10-05a` (2 prod rows). Deployed + verified
403
+ on prod. (jcardinal)
351
404
  - 2026-10-05 — **The asset-tag hold now only counts missing units on `SERIALIZED` items**
352
405
  (TRUE-82111). `readLinesMissingAssetTags()` treated "fewer linked units than quantity" as missing
353
406
  tags for every item, so accessories were reported short: first prod exception was `AC785544`, a
@@ -66,6 +66,7 @@ TOGA 2.0 client on `_underscore`, prod schema `Client_Nychh`. Supply-chain / ful
66
66
  - **Worker tiers:** `worker2` (2.0) runs the NYCHH asset-tag backfill; `worker`/`library` (1.0) run the NetSuite→TOGa Supply sync and the asset-tag verification/backfill diagnostics.
67
67
 
68
68
  ## Key features (this client)
69
+ - **Transfer-order "Order Shipped" email fires on the first tracking number, not the NetSuite shipped stage (2026-10-05, prod).** Warehouse marks shipped after delivery. NYCHH is the only client emailing off the NS shipped stage. See [TO notification emails](./features/transfer-order-notification-emails.md).
69
70
  - **Prod NetSuite feeds (2026-10-02):** only ITEM_FULFILLMENTS + INVENTORY_ADJUSTMENTS run (worker `511a7fd9`, 2026-09-30), cursors rewound to 2022/2023.
70
71
  - **TO destination + own ship-to (2026-10-02, library `8f974d93`, _underscore `6839dd3e`).** NetSuite-entered TOs landed on catch-all Location 2 (text match failed on Queens hyphens + multi-address sites). Destination now = mapped NetSuite address-book entry → text match → Location 2; TOs own `shipToAddressId`; Location auto-create uses default shipping. Mapping: 61 entries → 18 sites. See [TO import](./features/netsuite-transfer-order-import.md), [address-book mapping](../../1.0/apps/worker/workflows/netsuite-addressbook-location-mapping.md).
71
72
  - **TO↔customer-PO line matching fixed (2026-10-02, `1bc4de52`).** Old matcher picked unstocked duplicate PO lines and created empty PO lines; now ranks stocked lines and allows reuse. 161 TOs re-processed; 86 bad links over 62 TOs remained at last measure (re-process result pending). See [reconcile](../../1.0/apps/library/features/transfer-order-purchase-order-link-reconcile.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.951",
3
+ "version": "1.0.953",
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",