toga-ai 1.0.654 → 1.0.655

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.
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-28
10
- owners: ["jcardinal"]
9
+ updated: 2026-08-26
10
+ owners: ["jcardinal", "ajean"]
11
11
  files:
12
12
  - _underscore/ApiRequest.php
13
13
  related:
@@ -74,7 +74,33 @@ case-insensitive scan of already-set headers, so a caller-supplied `Content-Type
74
74
  save) lives **inside `execute()`** and would affect every framework caller, so it is deferred
75
75
  pending architecture review. Do **not** work around it per-caller.
76
76
 
77
+ - **Hand-rolled `Logs.Api` inserts must set `isAuthRequest`, `instanceId` and `hostname` — they are
78
+ NOT NULL with no defaults.** `_ApiRequest`'s own logging sets all three (`ApiRequest.php:260-264`),
79
+ so a helper that writes its own log row while only setting `direction`/`source`/`method`/`route`
80
+ throws `Column 'X' cannot be null` on **every** insert. Because such helpers are usually a
81
+ *wrapper* around the real call, the throw propagates out of the wrapped call and, under a
82
+ `catch(Throwable)`, turns the whole feature into a **silent no-op that still reports success**.
83
+ Two worker2 handlers shipped with exactly this bug and one went unnoticed for months — see
84
+ [netsuite-opportunity-sync](../../worker2/features/netsuite-opportunity-sync.md#gotchas--known-issues).
85
+ Prefer letting `_ApiRequest` log rather than writing `Logs.Api` by hand.
86
+ - **⚠ Auto-logging persists the `Authorization` header in PLAINTEXT.** `ApiRequest.php:267` writes
87
+ the full request headers to `Logs.Api.requestHeaders`, so any component that sends a bearer
88
+ token/API key and does not `setLogging(false)` is depositing that credential into the logs DB on
89
+ every call. Known live instance: `_underscore/Component/Api/Clickup/Clickup.php` (which also
90
+ **hardcodes** its token in committed source — flagged for a rotate-and-move-to-config ticket; 11
91
+ worker2 files use it). When adding a new component, decide deliberately: log and accept the header
92
+ exposure, strip/redact the auth header before the call is logged, or `setLogging(false)`.
93
+ - **`Logs.Api` is 1.4M+ rows and `source` is NOT indexed.** Any diagnostic query must be bounded by
94
+ `dtStamp` (indexed) or it times out — `WHERE source = '…'` alone will not return.
95
+
77
96
  ## Change history
97
+ - 2026-08-26 — Documented the **manual-insert column contract** (`isAuthRequest`, `instanceId`,
98
+ `hostname` are NOT NULL with no defaults, and `_ApiRequest` sets them at `ApiRequest.php:260-264`),
99
+ after a hand-rolled `logged()` helper in two worker2 handlers threw on every insert and silently
100
+ no-op'd an entire feature behind a `catch(Throwable)`. Also recorded that auto-logging persists the
101
+ **`Authorization` header in plaintext** into `Logs.Api.requestHeaders` (`ApiRequest.php:267`) — the
102
+ reason `_Component_Api_Clickup`'s hardcoded token needs rotation — and that `Logs.Api.source` is
103
+ unindexed at 1.4M+ rows, so queries must be bounded by `dtStamp`. (ajean)
78
104
  - 2026-08-05 — Documented (no code change) that the `direction = 'OUT'` api-log row is
79
105
  **committed immediately** inside `execute()` (`transactionCommit(DB_CLIENT_LOGS)`), so it
80
106
  survives a later rollback and `Logs_<Client>.Api` is definitive evidence of whether an outbound
@@ -11,4 +11,5 @@
11
11
  | [TableRecordModal (record-detail modal shell)](features/table-record-modal.md) | `TableRecordModal` is a **generic, presentational modal shell** for showing a single table record (row) in detail — typically opened from a table row click. | toga-blox/src/components/TableRecordModal/TableRecordModal.tsx, toga-blox/src/components/TableRecordModal/index.ts, toga-blox/src/components/TableRecordModal/tableRecordModal.module.css |
12
12
  | [Table component (cells, action cells, filters & sorts, hooks, theming)](features/table.md) | The `Table` component (`src/components/Table/`) is the **TanStack Table v8** building block behind the [Primary Table templates](primary-table-templates.md). | toga-blox/src/components/Table/hooks/useFetchTablePageMeta.ts, toga-blox/src/api/types.ts, toga-blox/src/components/Table/index.ts, toga-blox/src/components/Table/types.ts, toga-blox/src/components/Table/utils/buildTanstackColumns.tsx, toga-blox/src/components/Table/utils/resolveCellType.tsx, toga-blox/src/components/Table/components/cellTypes, toga-blox/src/components/Table/components/actionCells, toga-blox/src/components/Table/components/columnFiltersAndSorts, toga-blox/src/components/Table/hooks, toga-blox/src/components/Table/themeConfig |
13
13
  | [Talos AI-Assistant Component (launcher + slide-out chat panel)](features/talos-assistant.md) | `Talos` is a **shared, pure-UI** AI-assistant component in `@agilant/toga-blox`: a header launcher button (`TalosLauncher`) plus a slide-out chat panel (`TalosP | toga-blox/src/components/Talos/TalosLauncher.tsx, toga-blox/src/components/Talos/TalosPanel.tsx, toga-blox/src/components/Talos/TalosMessage.tsx, toga-blox/src/components/Talos/types.ts, toga-blox/src/components/Talos/theme.ts, toga-blox/src/components/Talos/helpers.tsx, toga-blox/src/components/Talos/stub.ts, toga-blox/src/components/Talos/Talos.module.css, toga-blox/src/components/Talos/index.ts, toga-blox/src/components/index.ts |
14
+ | [Toaster — host-token styling contract](features/toaster.md) | The blox `Toaster` is a CSS-Module component whose every visual property reads a `--toaster-*` custom property supplied by the **host app**. | toga-blox/src/components/Toaster/Toaster.module.css, toga-blox/src/components/Toaster/Toaster.tsx, toga-blox/src/components/Toaster/ToasterContext.tsx, toga2-commerce/src/styles/index.css, toga2-commerce/src/components/Toasters/ToasterList.tsx |
14
15
  | [Dynamic npm Publish Pipeline (branch → channel)](workflows/dynamic-publish-pipeline.md) | How `@agilant/toga-blox` (checkout folder `toga-blox-npm`, registry repo key `toga-blox`) publishes a per-environment npm **channel** (dist-tag) from a `_<mode> | toga-blox/.github/workflows/publish.yml, toga-blox/package.json, toga-blox/src/utils/getFontAwesomeIcon.tsx |
@@ -0,0 +1,100 @@
1
+ ---
2
+ title: Toaster — host-token styling contract
3
+ framework: "2.0"
4
+ repo: toga-blox
5
+ project: TOGa Blox
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-26
10
+ owners: [apeterson]
11
+ files:
12
+ - toga-blox/src/components/Toaster/Toaster.module.css
13
+ - toga-blox/src/components/Toaster/Toaster.tsx
14
+ - toga-blox/src/components/Toaster/ToasterContext.tsx
15
+ - toga2-commerce/src/styles/index.css
16
+ - toga2-commerce/src/components/Toasters/ToasterList.tsx
17
+ related:
18
+ - ../architecture.md
19
+ - ../../../standards/frontend.md
20
+ - ../../toga2-commerce/architecture.md
21
+ ---
22
+
23
+ ## Summary
24
+
25
+ The blox `Toaster` is a CSS-Module component whose every visual property reads a
26
+ `--toaster-*` custom property supplied by the **host app**. It is the clearest working example of
27
+ the blox styling contract described in `frontend.md` §13(e) — and of that contract's failure mode:
28
+ a host that defines **zero** tokens gets a component that renders **unstyled**, because every
29
+ declaration is invalid-at-computed-value-time and dropped.
30
+
31
+ As of 2026-08-26 the component ships **default values via `var(--token, fallback)`**, so it renders
32
+ fully styled with no host configuration, while any host-defined `--toaster-*` token still wins.
33
+ *(All Toaster changes below are working-tree on blox `feature-new-table` and commerce `TRUE-80707` —
34
+ not yet merged or published; verify against the installed version before relying on them.)*
35
+
36
+ ## How it works
37
+
38
+ ### Token layer with fallbacks (the pattern to copy)
39
+
40
+ Every declaration in `Toaster.module.css` is written `var(--toaster-x, <literal default>)`. This was
41
+ chosen over adding a `:root` token block inside blox because:
42
+
43
+ - blox ships **no** `:root` token block anywhere (§13e), and
44
+ - `dist/main.css` is **uncompiled** and must not be imported by consumers (§13f),
45
+
46
+ so a `:root` block would need a stylesheet import that no consumer makes. Fallbacks need nothing
47
+ imported. **This is the generalisable rule for any blox component that reads host tokens: ship
48
+ `var(--token, fallback)`, never a bare `var(--token)`.**
49
+
50
+ ### Do NOT declare a property on an element that also receives a host override class
51
+
52
+ `.prefixIcon` and `.closeBtn` deliberately declare **no `color`**. The host's `iconColor` /
53
+ `closeIconColor` classes land on those same elements at **equal specificity**, so a `color`
54
+ declared in the module would be resolved by **stylesheet source order** and could silently beat the
55
+ host's class (supply's mint/crimson icons losing to a grey default). They inherit from `.base`
56
+ instead, which any host class reliably overrides. In-code comments record this so it is not
57
+ "helpfully" re-added.
58
+
59
+ ### Host wiring — the variant-remap mechanism
60
+
61
+ The supported way for a host to express toast variants is to define the `--toaster-*` layer once,
62
+ then have each variant class **remap only the `--toaster-default-*` tokens** that blox's `.base`
63
+ already reads. Do **not** style toasts with utility classes on a wrapper — Tailwind utilities and
64
+ blox's `.base` land at equal specificity and compete unpredictably.
65
+
66
+ ```css
67
+ :root { --toaster-padding: 1.25rem; /* … full token set … */ }
68
+ .commerceToaster--error { --toaster-default-bg: …; --toaster-default-text: …; }
69
+ ```
70
+
71
+ `toga25-supply` defines the full set (~33 tokens) in `src/themeConfig.json`; `toga2-commerce` now
72
+ defines it in `src/styles/index.css` with `.commerceToaster--{success,error,restricted,default}`
73
+ variant classes consumed by `ToasterList.tsx`. Commerce's result was verified by `tsc -b` / `vite
74
+ build` and by inspecting the emitted CSS for the tokens and all four variant classes — **not
75
+ visually in a browser**.
76
+
77
+ ## Gotchas
78
+
79
+ - **A missing `var()` wrapper fails silently.** Two declarations read `min-width: (--toaster-min-width)`
80
+ / `max-width: (--toaster-max-width)` — no `var()`, so the parser dropped both. `toga25-supply`
81
+ masked this by re-declaring `min-width` on `.supplyToaster`; commerce had nothing to mask it, which
82
+ is how it surfaced. A host override can hide a blox CSS bug indefinitely.
83
+ - **`border-width` + `border-color` with no `border-style` renders no border at all.** `.base`
84
+ previously set the first two only; `border-style` (plus `border-radius`, `font-family`,
85
+ `font-size`, `margin-block`) has been added.
86
+ - **Order placement in commerce does not raise a success toast** — do not use it to test toast
87
+ styling. See `../../toga2-commerce/features/order-submit-sync-sequencing.md`.
88
+ - Commerce's `restricted` (orange) variant is commerce-only; supply has no equivalent. Commerce's
89
+ palette has no `crimson-600`, so supply's error icon color maps to `crimson-500`.
90
+
91
+ ## Change history
92
+
93
+ - 2026-08-26 — Toaster now ships **default styling** via `var(--token, fallback)` on every custom
94
+ property (renders styled with zero host config; host tokens still win) and gained the missing
95
+ `border-style`/`border-radius`/`font-family`/`font-size`/`margin-block` on `.base`. Fixed two
96
+ declarations missing their `var()` wrapper (`min-width`/`max-width`), which supply had been
97
+ masking. Recorded the deliberate **no `color` on `.prefixIcon`/`.closeBtn`** decision (equal
98
+ specificity with the host's `iconColor`/`closeIconColor` classes). `toga2-commerce` rewired to
99
+ supply's variant-remap mechanism and given the full `--toaster-*` token layer, replacing the
100
+ competing Tailwind-utility styling. (apeterson)
@@ -93,6 +93,27 @@ environment needs its own maintained `_<mode>` branch in `toga-blox-npm`.
93
93
  copy on the pushed branch, a new `_<mode>` channel will **not** publish unless the branch you push
94
94
  carries the wildcard-trigger version. When standing up a new channel, merge from a branch that has
95
95
  the dynamic `publish.yml` (or update it first).
96
+ - **Do NOT judge a blox branch's staleness by its behind-count.** `feature-new-table` reports
97
+ **"0 ahead / 47 behind"** `_sandbox-client` and `git merge-base --is-ancestor` says it is fully
98
+ contained — which reads as "stale, already merged, wrong base". **It is not.** Those 47 commits are
99
+ almost entirely `Merge branch 'feature-new-table' into _sandbox-client` and
100
+ `chore: bump version to 1.0.3xx` — **release plumbing generated ON `_sandbox-client` by the publish
101
+ flow and never merged back**. The actual source difference is **2 files** (`src/global.css`,
102
+ `src/index.ts`); the rest of the 334-file diff is `.claude/knowledge/` docs and `publish.yml`.
103
+ `feature-new-table` is the **active development branch** (tip 2026-08-24, `feat: prefer
104
+ server-supplied filter options in table meta`) and is the correct base. Verified 2026-08-26.
105
+ Corollary: the `publish.yml` divergence noted above is a **non-issue for publishing**, because
106
+ Actions runs the workflow on the **pushed** branch (`_sandbox-client`), which carries the dynamic
107
+ `_*` trigger.
108
+ - **A declared peer can be phantom — check before working around it.** Verified 2026-08-26:
109
+ `react-router-dom` appeared in blox's `peerDependencies` but only three files referenced it, two of
110
+ them tests; the single production reference was a **dead `import { Link }`** on line 1 of
111
+ `src/components/old/BaseButton/BaseButton.tsx` (the component renders an injected `linkComponent`
112
+ prop instead). The peer declaration and the dead import were removed *(working-tree on
113
+ `feature-new-table` — not yet merged or published)*; `react-router-dom` stays a
114
+ **devDependency** (`^6.30.1`) for the tests' `MemoryRouter`. Verified clean build, zero
115
+ `react-router-dom` references in `dist/`, and vitest identical to baseline. This peer was the
116
+ stated reason consumers pass `--legacy-peer-deps` against a router-7 app.
96
117
  - **FontAwesome v7 `style`-prop TS2322** (fixed 2026-07-21 in `getFontAwesomeIcon.tsx`, and a
97
118
  reusable pattern for any consumer). FA v7 types the `FontAwesomeIcon` `style` prop as
98
119
  `CSSProperties & CSSVariables`, where `CSSVariables` requires a `--fa-*` custom-property index
@@ -122,6 +143,12 @@ environment needs its own maintained `_<mode>` branch in `toga-blox-npm`.
122
143
  the pattern the consuming apps should follow (see the commerce workflow's secret-hygiene note).
123
144
 
124
145
  ## Change history
146
+ - 2026-08-26 — Corrected the branch-topology misreading: `feature-new-table`'s "0 ahead / 47 behind"
147
+ is release plumbing generated on `_sandbox-client`, not staleness — it remains the active dev
148
+ branch and correct base (real source delta: 2 files). Noted the `publish.yml` divergence is a
149
+ non-issue because Actions runs the pushed branch's copy. Removed the **phantom
150
+ `react-router-dom` peer** (its only production reference was a dead `Link` import in
151
+ `old/BaseButton`); it is now a devDependency for tests only. Working-tree, uncommitted. (apeterson)
125
152
  - 2026-08-04 — Recorded the prod/beta `.scss`-only build gotcha: the `_production`/`_beta`
126
153
  `build` copies only `src/**/*.scss`, so a component shipped with a plain `.css` + matching
127
154
  `import "./X.css"` publishes JS referencing a stylesheet absent from the tarball, breaking
@@ -16,6 +16,6 @@
16
16
  | [Multi-Tenant Resolution & Theming](features/multi-tenant-theming.md) | `toga2-commerce` serves multiple clients from one codebase. | src/themeConfig/themes.json, src/themeConfig/ThemeContext.tsx, src/themeConfig/types.ts, src/components/ThemeSwitcher/ThemeSwitcher.tsx, src/components/AuthLayout/AuthLayout.tsx, src/api/axiosInstance.ts, src/contexts/AuthContext.tsx, tailwind.config.js |
17
17
  | [Order-submit sync sequencing (useSubmitOrder) — why these calls must not run in parallel](features/order-submit-sync-sequencing.md) | Submitting an order from the cart fires **two independent sync routines** — one for the sales-order header (`syncSalesOrderData`) and one for the line items (`s | toga2-commerce/src/pages/OrderDetails/hooks/useSubmitOrder.ts, toga2-commerce/src/api/syncSalesOrdersDataFromLocalStorageCartToApi.ts, toga2-commerce/src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts |
18
18
  | [Config-Driven Shipping Cost Waiver (Standard Ground free for computer kits)](features/shipping-cost-waiver-gating.md) | On the toga2-commerce **Cart** page, a shipping option's **cost** can be waived by config using the same `PrimaryItemShippingRule` vocabulary that drives expedi | toga2-commerce/src/pages/Cart/helpers/shippingOptionGates.ts, toga2-commerce/src/pages/Cart/viewModel/FIELDS/shared/shippingOptionGates.ts, toga2-commerce/src/pages/Cart/CartPage.tsx |
19
- | [AWS Amplify Build & Deploy (non-prod environments)](workflows/amplify-build-and-deploy.md) | How `toga2-commerce` (React + Vite, "commerce2-react") builds and deploys on **AWS Amplify**. | toga2-commerce/amplify.yml, toga2-commerce/.gitattributes, toga2-commerce/package.json, toga2-commerce/.github/workflows/sync-stage-environments.yml |
19
+ | [AWS Amplify Build & Deploy (non-prod environments)](workflows/amplify-build-and-deploy.md) | How `toga2-commerce` (React + Vite, "commerce2-react") builds and deploys on **AWS Amplify**. | toga2-commerce/amplify.yml, toga2-commerce/.gitattributes, toga2-commerce/package.json, toga2-commerce/.npmrc, toga2-commerce/vite.config.ts, toga2-commerce/.github/workflows/sync-stage-environments.yml |
20
20
  | [Cart e2e — Cypress conventions & harness (toga2-commerce)](workflows/cypress-testing.md) | The Cypress **e2e** convention set for `toga2-commerce`, and the first **active** e2e coverage for the **Cart** page (`cartV2.cy.ts`, slice 1 — 12 tests, verifi | toga2-commerce/cypress/e2e/cartPage/cartV2.cy.ts, toga2-commerce/cypress/fixtures/cart/fetchSingleUserAdmin.json, toga2-commerce/cypress/fixtures/cart/fetchLocations.json, toga2-commerce/cypress/fixtures/cart/fetchUserShippingMethods.json, toga2-commerce/cypress/support/commands.ts, toga2-commerce/cypress/support/e2e.ts, toga2-commerce/src/pages/Cart/CartPage.tsx, toga2-commerce/src/pages/Cart/view/cartForm/CartForm.tsx, toga2-commerce/src/pages/Cart/view/cartForm/CartFormSection.tsx, toga2-commerce/src/pages/Cart/view/cartTable/CartContentsTable.tsx, toga2-commerce/src/pages/Cart/view/cartTable/CartTableItem.tsx, toga2-commerce/src/components/Inputs/AdvancedInput.tsx, toga2-commerce/src/components/BaseButton/BaseButton.tsx |
21
21
  | [Diagnosing ERR_HTTP2_PROTOCOL_ERROR (one client fails, everyone else is fine)](workflows/http2-protocol-error-diagnosis.md) | When a Chromium browser (Chrome / Edge) shows **`ERR_HTTP2_PROTOCOL_ERROR`** loading a `*.togacommerce.com` tenant for **one client/network but works for the TO | |
@@ -6,8 +6,8 @@ project: TOGa Commerce
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-14
10
- owners: ["bala"]
9
+ updated: 2026-08-26
10
+ owners: ["bala", "apeterson"]
11
11
  files:
12
12
  - toga2-commerce/src/pages/OrderDetails/hooks/useSubmitOrder.ts
13
13
  - toga2-commerce/src/api/syncSalesOrdersDataFromLocalStorageCartToApi.ts
@@ -134,9 +134,22 @@ Three changes, in `toga2-commerce`:
134
134
  - **⚠ Omitting a line from the rebuilt cart DELETES it from a placed order.** `processItems` with
135
135
  `getDeleteItems = true` derives the delete set by difference, so "don't re-add this one" and
136
136
  "destroy this one" are the same instruction on this path.
137
+ - **There is NO success toaster on order placement — by design, not a bug.** `useSubmitOrder` calls
138
+ `addToaster` **only in its `catch` block** (`status: 'error'`). Both success paths navigate away
139
+ before anything could render: cart → `createNewSalesOrderSubmit()` →
140
+ `navigate("/order-details?uuid=…&type=orderPlaced")` (no `addToaster` anywhere in it); edit-order →
141
+ `window.location.href = getSupplyRedirectLink(orderUuid)`. **Consequence for debugging: placing an
142
+ order is not a valid way to test toaster styling.** Use a path that stays mounted — add/edit
143
+ shipping address (Cart or Account), Account → My Settings, or Cart → add new user; for the error
144
+ variant, force a submit failure to hit the catch block. See
145
+ [blox Toaster](../../toga-blox/features/toaster.md).
137
146
 
138
147
  ## Change history
139
148
 
149
+ - 2026-08-26 — Recorded that order placement raises **no success toaster by design** (`addToaster`
150
+ is only in the `catch`; both success paths navigate away), and listed the mounted-path
151
+ alternatives for testing toaster styling. (apeterson)
152
+
140
153
  - 2026-08-14 — Recorded **how order lines are actually written**, learned while adding a
141
154
  server-side order guard: the header sync `delete`s `salesOrderItems` from the edit `PUT` (line
142
155
  20), and lines are written **one request at a time** — new lines via the nested
@@ -6,16 +6,19 @@ project: TOGa Commerce
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-07-21
10
- owners: ["jcardinal"]
9
+ updated: 2026-08-26
10
+ owners: ["jcardinal", "apeterson"]
11
11
  files:
12
12
  - toga2-commerce/amplify.yml
13
13
  - toga2-commerce/.gitattributes
14
14
  - toga2-commerce/package.json
15
+ - toga2-commerce/.npmrc
16
+ - toga2-commerce/vite.config.ts
15
17
  - toga2-commerce/.github/workflows/sync-stage-environments.yml
16
18
  related:
17
19
  - ../../toga2-supply/workflows/amplify-build-and-deploy.md
18
20
  - ../../toga-blox/workflows/dynamic-publish-pipeline.md
21
+ - ../../../standards/frontend.md
19
22
  ---
20
23
 
21
24
  ## Summary
@@ -69,6 +72,10 @@ full cross-repo publish-then-build model. **The channel must exist before the ap
69
72
 
70
73
  ## Edge cases & escalation
71
74
 
75
+ > The 2026-08-26 commerce-side fixes below (`.npmrc` flag, added peers, `resolve.dedupe`, the
76
+ > `--toaster-*` token layer, the `ToasterList` rewiring) are **working-tree on branch `TRUE-80707`** —
77
+ > uncommitted, unmerged, and not in any deployed build.
78
+
72
79
  - **All the toga2-supply gotchas apply** (console build spec overriding repo `amplify.yml`;
73
80
  CRLF breaking block-scalar parsing; Vite silently falling back to `.env`/beta when a mode's env
74
81
  file is missing). See the related doc.
@@ -77,14 +84,44 @@ full cross-repo publish-then-build model. **The channel must exist before the ap
77
84
  only triggers on branches listed in `on.push.branches` and only tags branches with an explicit
78
85
  `elif` case — creating the branch alone does nothing; the workflow file must be present on that
79
86
  branch (GitHub Actions runs the workflow version on the pushed branch).
80
- - **`--legacy-peer-deps` is required** because blox declares `react-router-dom@^6` as a peer while
87
+ - **`--legacy-peer-deps` is required** because blox declared `react-router-dom@^6` as a peer while
81
88
  commerce uses `react-router-dom@7` (npm 7+ `ERESOLVE` otherwise). All non-prod build scripts use it.
89
+ (That peer was **phantom** and was removed from blox on 2026-08-26 — see the blox publish-pipeline
90
+ doc — but the flag stays until a build without it is proven.)
91
+ - **When one app installs a shared lib and a sibling app does not, compare `.npmrc` BEFORE touching
92
+ peer ranges.** A local `npm i` in commerce failed `ERESOLVE` on blox's router peer while
93
+ `toga25-supply` installed cleanly against the **same** blox version and the **same** router major.
94
+ The difference was not the peer range: supply carries `legacy-peer-deps=true` in its committed
95
+ `.npmrc` and commerce did not. Fix was adding `legacy-peer-deps=true` to commerce's `.npmrc` — a
96
+ tracked file, so it also reaches the Amplify build (the amplify.yml flag alone does not cover a
97
+ developer's local install).
82
98
  - **`--legacy-peer-deps` disables npm's peer auto-install**, so any blox peer that is NOT in
83
99
  commerce's own `dependencies` and NOT in blox's `dependencies` must be added explicitly, or Vite/
84
100
  Rollup fails with `failed to resolve import "<pkg>"`. Concretely, commerce had to add the table
85
- peers: `@tanstack/react-table@^8`, `react-table@^7.8.0`, `react-table-sticky@^1.1.3`. Check
86
- `npm view @agilant/toga-blox@<tag> peerDependencies` against commerce deps when a resolve error
87
- appears.
101
+ peers: `@tanstack/react-table@^8`, `react-table@^7.8.0`, `react-table-sticky@^1.1.3`, and on
102
+ 2026-08-26 `@fortawesome/free-solid-svg-icons@^6.7.2` + `react-multi-select-component@^4.3.4`.
103
+ Check `npm view @agilant/toga-blox@<tag> peerDependencies` against commerce deps when a resolve
104
+ error appears.
105
+ - **A BARREL import is what drags in peers you never use.** The failure text was
106
+ `Rollup failed to resolve import "@fortawesome/free-solid-svg-icons" from
107
+ node_modules/@agilant/toga-blox/dist/components/Input/Input.js`, triggered by
108
+ `AuthLayout.tsx` doing `import { EnvironmentBadge } from "@agilant/toga-blox"` — the package
109
+ index pulls `Input` and `MultiSelect` along with it. A deep import would not have surfaced it, so
110
+ expect the missing-peer set to be blox's **whole** surface, not the components you reference.
111
+ - **`resolve.dedupe` was missing from commerce's `vite.config.ts`** (a `frontend.md` §11 MUST), which
112
+ made consuming blox via a local symlink hazardous: `toga-blox-npm/node_modules/` contains its own
113
+ `react@18.3.1`/`react-dom@18.3.1`, and because blox ships **unbundled**, imports resolve against
114
+ the nearest `node_modules` — under a symlink that is blox's own, giving a second React copy. This
115
+ is precisely why supply's symlink workflow works (it has always deduped
116
+ `react`/`react-dom`/`@tanstack/react-table`) and commerce's did not. Commerce now dedupes
117
+ `react`, `react-dom`, `react-hook-form`, `react-router-dom`, `framer-motion`,
118
+ `@tanstack/react-query`, `@tanstack/react-table`.
119
+ - **`package.json` pins blox with a CARET** (`"@agilant/toga-blox": "^1.0.322-sandbox-client.117"`),
120
+ violating `frontend.md` §13(a) — some published versions ship an empty `dist`, and a caret can
121
+ float you onto one. Pin exact. Related: the `TRUE-80707` branch pin is **11 versions behind** the
122
+ `_sandbox-client` branch pin (`1.0.333-sandbox-client.134`); reconcile before merging.
123
+ - **Secret hygiene re-confirmed unremediated 2026-08-26** — the live tokens are still in the tracked
124
+ `.npmrc` in **both** `toga2-commerce` and `toga25-supply`. See the remediation below.
88
125
  - **Secret hygiene (open remediation):** `toga2-commerce/.npmrc` is **git-tracked** — it is listed
89
126
  in `.gitignore` but was committed before that, so git still tracks it, and it holds a live
90
127
  FontAwesome registry token and a live npm auth token in plaintext. Remediation: (1) **rotate both
@@ -95,6 +132,13 @@ full cross-repo publish-then-build model. **The channel must exist before the ap
95
132
 
96
133
  ## Change history
97
134
 
135
+ - 2026-08-26 — Dependency-resolution findings from a local commerce build: root-caused an
136
+ `ERESOLVE` to the **missing `legacy-peer-deps=true` in commerce's tracked `.npmrc`** (supply had
137
+ it) rather than blox's peer range; recorded the barrel-import trigger for missing peers (added
138
+ `@fortawesome/free-solid-svg-icons`, `react-multi-select-component`); added the previously absent
139
+ `resolve.dedupe` to `vite.config.ts` and explained why supply can symlink blox safely and commerce
140
+ could not; flagged the caret blox pin and the 11-version pin drift on `TRUE-80707`; re-confirmed
141
+ the committed `.npmrc` tokens are still unremediated. All working-tree, uncommitted. (apeterson)
98
142
  - 2026-07-21 — Reworked `amplify.yml` to a **fully dynamic inline build** (no per-branch `case`, no
99
143
  `npm run <script>`): derive mode from `$AWS_BRANCH`, dynamic `npm install
100
144
  "@agilant/toga-blox@$MODE"`, `npx tsc -b` (composite), `npx vite build --mode "$MODE"`. Re-export
@@ -26,6 +26,7 @@
26
26
  | [Etilize Item Translation Import](features/etilize-item-translation-import.md) | The abstract worker class `_Worker_Etilize_ItemTranslations` imports **non-English** item text from Etilize into the client's `ItemTranslations` table. | worker2/Worker/Etilize/ItemTranslations.php |
27
27
  | [Monitoring Framework (Orchestrator + Child Monitors)](features/monitoring-framework.md) | A unified, DB-driven monitoring framework for business-critical data flows (Compass POs, Prudential asset imports, AIG closed claims, …). | worker2/Worker/Monitor.php, worker2/Worker/Monitors/, worker2/Worker/Monitors/RateEntitlement.php, worker2/Worker/Notification/Email.php, worker2/Worker/Rate.php, dbchanges2/Core/2026-05-21 - Monitors.sql, dbchanges2/Core/2026-06-29a - Rate Entitlement Contract Monitor.sql |
28
28
  | [NetSuite Integrations Monitor (Monitor/Operations/NetsuiteIntegrations)](features/netsuite-integrations-monitor.md) | `_Worker_Monitor_Operations::NetsuiteIntegrations()` is a cross-client health check that detects **stuck NetSuite ↔ 2.0 integrations**. | worker2/Worker/Monitor/Operations.php, dbchanges2/Core/2026-08-10a - Netsuite Integrations Monitor.sql, _underscore/Database.php, _underscore/Query.php |
29
+ | [ClickUp Opportunity Client vs End-Customer Labels (Stakeholders / End Customer)](features/netsuite-opportunity-client-labels.md) | The NetSuite→ClickUp opportunity task carries **two** customer labels, not one (TRUE-81010, PR #142): - **Stakeholders** = the **client** — the consolidated par | worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Netsuite/OpportunityStakeholderDigest.php, worker2/Worker/Netsuite/OpportunityStakeholderBackfill.php |
29
30
  | [NetSuite ↔ ClickUp / TOGA Opportunity Sync (API Message Queue + worker2 webhook)](features/netsuite-opportunity-sync.md) | Outbound sync from NetSuite to TOGA for the record types the Forecast2 importer pulls (opportunities first; sales/items/etc. | worker2/Worker/Netsuite.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Clickup.php, worker2/Worker/Clickup/Opportunity.php, worker2/Controller/Index.php, _underscore/Worker.php, test/@dave/NetSuite/api-message-queue/lib_amq_queue.js, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/NetSuite/api-message-queue/ue_amq_drain.js, test/@dave/NetSuite/api-message-queue/ss_amq_drain.js, test/@dave/NetSuite/api-message-queue/DEPLOY_RUNBOOK.md, test/@dave/clickup/backfill_opportunity_numbers.php, test/@dave/clickup/probe_opportunity_fields.php, test/@dave/probe_clickup_desc_match.php, test/@dave/test_model_load_behavior.php, dbchanges2/Forecast/2026-06-25a - Add unique index on Opportunities netsuiteOpportunityInternalId.sql, _underscore/Model/Forecast/Opportunity.php, test/@dave/approach/TRUE-80044.md, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
30
31
  | [NetSuite → Forecast Open-Orders Sync (salesOrder webhook → OpenOrderItems)](features/netsuite-salesorder-open-orders-sync.md) | Webhook-driven, single-record port of the legacy open-orders importer (TRUE-79142). | worker2/Worker/Netsuite/SalesOrder.php, worker2/Worker/Netsuite.php, worker2/Component/Forecast/Db/Db.php, worker2/Worker/Netsuite/Location.php, test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_amq_invoice_resync_salesorder.js, test/@dave/probe_salesorder_rest_shape.php, test/@dave/probe_open_order_lines.php, test/@dave/check_so_status.php, test/@dave/check_so_history.php, test/@dave/probe_so_rest_lines.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_open_order_gating.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
31
32
  | [Toga → NetSuite Sales-Order Push (multi-client, mapping-driven worker)](features/netsuite-salesorder-outbound-push.md) | The **outbound** half of `worker2/Worker/Netsuite/SalesOrder.php` (everything from the `OUTBOUND PUSH (REST)` banner down) pushes a Toga sales order **into** Ne | worker2/Worker/Netsuite/SalesOrder.php, test/@Bala/tests/netsuite_salesorder_payload_tests.php, worker/crons/toga2/prudential/transmissions_to_netsuite.php |
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-13
10
- owners: [jcardinal, dfranks, mhammontree, tcox, bala]
9
+ updated: 2026-08-26
10
+ owners: [jcardinal, dfranks, mhammontree, tcox, bala, ajean]
11
11
  files:
12
12
  - worker2/Worker/
13
13
  - worker2/Controller/Index.php
@@ -309,10 +309,25 @@ Rules: rename **both sides in one PR and one deploy**; grep every repo for the o
309
309
  host. **Production's `[databaseClient]` additionally exposes a `readhost`** (a reader endpoint);
310
310
  use it for **bulk reads** rather than the primary. Credential *values* live only in the ini files —
311
311
  never copy them anywhere else.
312
+ - **A new action does not run on a schedule until a `Core.CronJobs` row exists.** The schedule
313
+ registry is **data, not code** — shipping the PHP file changes nothing on its own. Ship the code
314
+ deploy and the `dbchanges2` cron-row migration together (see "Adding a new cron job" above).
315
+ - **For a code path gated behind a RARE condition, build a dry-run entry point and fire it
316
+ deliberately — do not wait for the path to fire naturally.** A dry run enqueued against production
317
+ found a fatal bug in ~2 seconds that two weeks of green telemetry had not: the path was reached
318
+ only when a NetSuite opportunity webhook carried a presales lead (83% return
319
+ `skipped (no presales lead)`; ~36 tasks in 14 days), so natural traffic would have hidden the
320
+ failure indefinitely. The dry run must exercise the **same helpers** as the real path — a separate
321
+ test harness that reimplements them proves nothing. See
322
+ [netsuite-opportunity-sync](./netsuite-opportunity-sync.md#gotchas--known-issues) for what it caught.
312
323
  - See [architecture.md](../architecture.md) for the always-HTTP-200 rule and the
313
324
  commit-before-SQS transaction pattern that the worker relies on.
314
325
 
315
326
  ## Change history
327
+ - 2026-08-26 — Added two operational gotchas: a new action **does not run on a schedule until its
328
+ `Core.CronJobs` row exists** (schedule is data, not code), and the practice of building a
329
+ **dry-run entry point for a rare-gated path** rather than waiting for it to fire — one found a
330
+ fatal `Logs.Api` insert bug in seconds after two weeks of green telemetry missed it. (ajean)
316
331
  - 2026-08-13 — Added a **database-side test for "the tier stopped consuming"**, which resolves the
317
332
  absent-`WorkerJobs`-row ambiguity without SQS metrics: compare created vs. completed
318
333
  `Core.WorkerJobs` for the day — on dev-sandbox 2026-08-12, **1,548 created / 1,547 never
@@ -0,0 +1,138 @@
1
+ ---
2
+ title: ClickUp Opportunity Client vs End-Customer Labels (Stakeholders / End Customer)
3
+ framework: "2.0"
4
+ repo: worker2
5
+ project: Worker
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-26
10
+ owners: ["ajean"]
11
+ files:
12
+ - worker2/Worker/Netsuite/Opportunity.php
13
+ - worker2/Worker/Netsuite/OpportunityStakeholderDigest.php
14
+ - worker2/Worker/Netsuite/OpportunityStakeholderBackfill.php
15
+ related:
16
+ - ./netsuite-opportunity-sync.md
17
+ - ./creating-worker-actions.md
18
+ - ./notification-email.md
19
+ ---
20
+
21
+ ## Summary
22
+
23
+ The NetSuite→ClickUp opportunity task carries **two** customer labels, not one (TRUE-81010, PR #142):
24
+
25
+ - **Stakeholders** = the **client** — the consolidated parent company we contract with.
26
+ - **End Customer** = the NetSuite opportunity `entity` — the specific (often sub-)customer the work
27
+ is for.
28
+
29
+ Before this change the sync pushed `entity->refName` into **Stakeholders**, flattening the two
30
+ levels of the NetSuite customer hierarchy into one field. Verified against production that `entity`
31
+ is the **end customer**, and the client is the consolidated parent (NetSuite `custentity10`):
32
+ `Forecast.Customers` row `"6096:6308 Compass Group"` hangs off `Forecast.ConsolidatedCustomers`
33
+ `"Office Depot"` — Office Depot is who we bill, Compass Group is who receives.
34
+
35
+ Two support actions ship with it: a **daily digest** of label options still to create in ClickUp,
36
+ and a **seed-if-empty backfill** for the 761 tasks that already exist.
37
+
38
+ ## Key files / entry points
39
+
40
+ - `worker2/Worker/Netsuite/Opportunity.php` — `deriveLabelNames()`, `splitCustomerRefName()`,
41
+ `buildCustomFields()`. The sync itself; see [netsuite-opportunity-sync](./netsuite-opportunity-sync.md).
42
+ - `worker2/Worker/Netsuite/OpportunityStakeholderDigest.php` — action
43
+ `Netsuite/OpportunityStakeholderDigest/Daily` (`dryRun=false`, `to=null`).
44
+ - `worker2/Worker/Netsuite/OpportunityStakeholderBackfill.php` — action
45
+ `Netsuite/OpportunityStakeholderBackfill/Backfill` (`dryRun=true`, `taskId=null`, `exactOnly=true`).
46
+
47
+ **ClickUp field ids** on list `901111987449` (Presales Qualification), both `labels` type with ~169
48
+ options each: `Stakeholders` = `ec15edfd-5920-4c12-a1dc-94ede4a62438`,
49
+ `End Customer` = `67bb359f-fa59-40e1-b94a-0076450bb7c0`.
50
+
51
+ ## How it works
52
+
53
+ **Resolution is entirely local — no extra NetSuite call.** The opportunity payload carries only a
54
+ flat `entity` ref, and `expandSubResources` does **not** dereference it to the consolidated parent.
55
+ So the client is resolved through Forecast:
56
+
57
+ ```
58
+ Opportunities.customerId → Customers.consolidatedCustomerId → ConsolidatedCustomers.name
59
+ ```
60
+
61
+ `deriveLabelNames()` then applies three rules, each sized against production data:
62
+
63
+ 1. **No consolidated parent → the entity IS the client.** 186 of 759 task-bearing opportunities
64
+ (24.5%) have no parent. Stakeholders then receives exactly what it received before the change,
65
+ so the swap is not a regression for them.
66
+ 2. **Mirror when equal.** For 678 of 761 the parent is the same company as the entity
67
+ (`"4679 Staples, Inc."` → `"Staples, Inc."`). Both fields carry the same value rather than one
68
+ being left blank — which makes an **empty field an unambiguous signal that resolution FAILED**,
69
+ and nothing else.
70
+ 3. **Genuinely different parent** in only 83 of 573 parented opportunities (14.5%, 25 distinct
71
+ pairs) — the case the split exists for.
72
+
73
+ Net effect measured on production: Stakeholders exact-match rose from **31.5% to 36.4%** (47.0%
74
+ including fuzzy).
75
+
76
+ ### Daily digest (`OpportunityStakeholderDigest/Daily`)
77
+
78
+ The originally-shipped per-opportunity "option missing" email does not survive real data — **402 of
79
+ 759** task-bearing opportunities fail to resolve a client label, and a second field roughly doubles
80
+ that, i.e. hundreds of mails a day. It is replaced by **one spreadsheet each morning** to
81
+ `pmteam@togatech.com` listing every label option still to add, sorted by opportunity count.
82
+
83
+ **Stateless by design:** recomputed from live Forecast rows + live ClickUp options on every run. No
84
+ table, no migration, no reconciliation state — a row simply disappears from tomorrow's digest once
85
+ someone adds the option in ClickUp.
86
+
87
+ ### Seed-if-empty backfill (`OpportunityStakeholderBackfill/Backfill`)
88
+
89
+ Existing tasks are never reached by the sync (see the claim gotcha below), so a separate backfill is
90
+ the only way to populate them.
91
+
92
+ - **Seed-if-empty, never overwrite.** A ClickUp `labels` write is **add-only and therefore
93
+ accumulates**: re-pointing an opportunity would leave the task carrying two clients with no signal
94
+ which is current. Seeding only empty fields also makes re-runs write nothing.
95
+ - **`exactOnly` defaults TRUE.** On live data `"Lee County Sheriff's Office"` scores **86.8%**
96
+ against the option `"Polk County Sheriff Office"` — over the 85% fuzzy floor, wrong county, 12
97
+ opportunities affected, and nothing would have surfaced it. Fuzzy matching is opt-in only.
98
+ - **Pacing is 700 ms per REQUEST, not per task** (~85/min against ClickUp's 100/min/token). A task
99
+ costs a variable 1–3 requests, so per-task pacing cannot bound the request rate.
100
+
101
+ ## Gotchas / known issues
102
+
103
+ - **`splitCustomerRefName()` must handle composite sub-customer numbers.** The original pattern
104
+ `/^(\d+)\s+(.*)$/` (digits then whitespace) could not parse `"6096:6308 Compass Group"` — the colon
105
+ broke the match, the number stayed in the name, and normalization produced
106
+ `"60966308compassgroup"`, which can never match a ClickUp option. **15.4% of `Forecast.Customers`
107
+ use this composite form** (1,418 of 9,211) and they are precisely the hierarchical customers this
108
+ feature exists to distinguish. Now `/^(\d+(?::\d+)*)\s+(.*)$/` — still requiring the whitespace so
109
+ `"3M Company"` is not decapitated.
110
+ - **Option ids ride on `$body`, not on extra positional arguments.** `buildCustomFields()` used to
111
+ take the Stakeholders option id as an optional 3rd argument, but `updateTask()` called it as
112
+ `buildCustomFields($body, $presalesUserId)` — two arguments — so that path could never set the
113
+ field even if `UPDATE_CLICKUP` were true. Passing option ids on `$body` (the pattern
114
+ `opportunityStageOrderIndex` already used) makes the divergence structurally impossible and keeps
115
+ the signature under the 4-positional-parameter limit.
116
+ - **The empty field means "resolution failed" — that is the whole diagnostic.** Because of the
117
+ mirror-when-equal rule, a blank Stakeholders or End Customer is never "same as the other one";
118
+ it is always a missing ClickUp option or a failed lookup, and it will appear in the next digest.
119
+ - **The digest emails INLINE, it does not attach.** See
120
+ [notification-email](./notification-email.md#gotchas--known-issues) — the EmailTemplate action has
121
+ no attachment parameter and `_Email::addAttachment()` takes a **file path**, which an enqueued job
122
+ running later may no longer find.
123
+ - **CSV values beginning `=`, `+`, `-` or `@` are apostrophe-prefixed** against spreadsheet formula
124
+ injection before they go into the digest.
125
+
126
+ ## Change history
127
+ - 2026-08-26 — **Built the client vs end-customer split (TRUE-81010, PR #142, merged).** Stakeholders
128
+ now receives the consolidated parent (the client, NS `custentity10`) and a new **End Customer**
129
+ field receives the opportunity `entity`; resolution is local via
130
+ `Opportunities.customerId → Customers.consolidatedCustomerId → ConsolidatedCustomers.name` with no
131
+ extra NetSuite call. Three mapping rules sized against production (no-parent → entity is the
132
+ client, 24.5%; mirror-when-equal, 678/761; genuinely-different parent, 83/573) lifted Stakeholders
133
+ exact-match 31.5% → 36.4%. Fixed `splitCustomerRefName()` to parse composite sub-customer numbers
134
+ (`6096:6308 …`, 15.4% of Forecast.Customers). Moved option ids onto `$body` to kill a
135
+ `buildCustomFields()` optional-argument divergence. Added the daily
136
+ `OpportunityStakeholderDigest` (one spreadsheet to pmteam@ replacing hundreds of per-miss mails;
137
+ stateless) and the `OpportunityStakeholderBackfill` (seed-if-empty, `exactOnly` default true,
138
+ 700 ms per-request pacing). (ajean)
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-19
10
- owners: ["dfranks", "kyalamarthi"]
9
+ updated: 2026-08-26
10
+ owners: ["dfranks", "kyalamarthi", "ajean"]
11
11
  files:
12
12
  - worker2/Worker/Netsuite.php
13
13
  - worker2/Worker/Netsuite/Opportunity.php
@@ -29,6 +29,7 @@ files:
29
29
  - test/@dave/approach/TRUE-80044.md
30
30
  - worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php
31
31
  related:
32
+ - ./netsuite-opportunity-client-labels.md
32
33
  - ./netsuite-salesorder-open-orders-sync.md
33
34
  - ../architecture.md
34
35
  ---
@@ -152,6 +153,10 @@ Shared helpers: `buildCustomFields()` (the field array, used by both create and
152
153
  `Opportunity #` (`a5529cdc-…`) ← `tranId`; `Customer #` (`170dc118-…`) ← `entity->refName`
153
154
  (the customer **name**, deliberately — not the NetSuite customer number). Plus `Sales Rep`,
154
155
  `Presales Lead Email`, and `Presales Lead` (users field).
156
+ **Since 2026-08-26 the two customer *labels* fields are a separate subject** — `Stakeholders`
157
+ carries the **client** (the consolidated parent) and `End Customer` carries the opportunity
158
+ `entity`. See [ClickUp Opportunity Client vs End-Customer Labels](./netsuite-opportunity-client-labels.md);
159
+ do not re-derive that mapping here.
155
160
  - The ClickUp token comes from `[clickup] token` in the worker2 config (legacy webhook received
156
161
  it on the payload; the new contract drops it).
157
162
  - Every ClickUp `_ApiRequest` calls `setLogging(self::CLICKUP_API_LOGGING_ENABLED=false)`, so ClickUp
@@ -168,11 +173,11 @@ Shared helpers: `buildCustomFields()` (the field array, used by both create and
168
173
  removing that `setLogging(false)` is under review (handle the 20–50 KB payload volume the
169
174
  prod-appropriate way — retention/sampling/summary — not by defaulting logging off).
170
175
 
171
- ## Atomic ClickUp-task claim (TRUE-80044 — decided approach, not yet built)
176
+ ## Atomic ClickUp-task claim (TRUE-80044 — BUILT, in production)
172
177
 
173
178
  Concurrent NetSuite opportunity webhooks (a real edit racing NetSuite's hourly `OPP` fallback) can both
174
179
  pass the non-atomic find-then-create in `maybeCreateClickupTask()` and create **duplicate** ClickUp
175
- tasks. The fix is a **race-safe DB claim** on the **existing `Forecast.Opportunities` table** — an added
180
+ tasks. The fix — **now built and running in production** — is a **race-safe DB claim** on the **existing `Forecast.Opportunities` table** — an added
176
181
  `clickupTaskId` column — **not** a new dedicated table. (A prior plan proposed a new
177
182
  `NetsuiteOpportunityClickupTask` table in `Client_True`; reviewer Rohan Girish rejected that and mandated
178
183
  reusing `Forecast.Opportunities`.)
@@ -198,9 +203,24 @@ How the claim is race-safe:
198
203
  - **Edge case:** if `importOpportunity()` was skipped (no NS status → no Forecast row), there is nothing
199
204
  to claim; that rare path falls back to today's unguarded create.
200
205
 
201
- Requires a new `dbchanges2/Forecast/` ALTER adding the `clickupTaskId` column, and a
206
+ Required a new `dbchanges2/Forecast/` ALTER adding the `clickupTaskId` column, and a
202
207
  `_underscore/Model/Forecast/Opportunity.php` field addition. Plan: `test/@dave/approach/TRUE-80044.md`.
203
208
 
209
+ ### Consequence: `updateTask()` is unreachable — `UPDATE_CLICKUP` alone does NOT revive it
210
+
211
+ `maybeCreateClickupTask()` claims the row **before** it ever looks at ClickUp
212
+ (`Opportunity.php:40`): `UPDATE Opportunities SET clickupTaskId='PENDING' WHERE … AND clickupTaskId
213
+ IS NULL`. Once `clickupTaskId` holds a real id, **every later webhook for that opportunity returns
214
+ `skipped (already claimed)` and exits** — it never reaches the find-then-update branch.
215
+
216
+ So `updateTask()` is dead **twice over**: behind `UPDATE_CLICKUP = false` *and* behind the claim.
217
+ Flipping the constant activates ~180 lines of code that have **never run in production** and still
218
+ reaches **none** of the 761 existing tasks — only a separate backfill action reaches those (see
219
+ [client vs end-customer labels](./netsuite-opportunity-client-labels.md#seed-if-empty-backfill-opportunitystakeholderbackfillbackfill)).
220
+ `UPDATE_CLICKUP` stays `false`. **This is a trap for the next developer:** the constant's own comment
221
+ says nothing about the claim interaction, so reading the constant alone gives you the wrong model of
222
+ what enabling it would do.
223
+
204
224
  ## Data model
205
225
 
206
226
  Writes `Forecast.Opportunities` (header) + `Forecast.OpportunityItems` (children), faithful to
@@ -340,8 +360,8 @@ None — platform-wide Forecast sync.
340
360
  (create branch, new task `868k2rfj8`).
341
361
  - **ClickUp dedup keys on the `Opportunity #` field, not a DB column.** Historically we did NOT add
342
362
  a `clickupTaskId` column — the dedup lookup queried ClickUp itself by `Opportunity #` (= tranId).
343
- **This is being replaced (TRUE-80044) by a race-safe DB-claim approach — see "Atomic ClickUp-task
344
- claim" below.** Consequences of the field-lookup approach to know:
363
+ **This has been replaced (TRUE-80044) by the race-safe DB-claim approach — see "Atomic ClickUp-task
364
+ claim" above.** Consequences of the field-lookup approach to know:
345
365
  - **Legacy tasks had `Opportunity #` empty** (the old `webhook/` handler never set it), so they
346
366
  can't be matched until backfilled — see the backfill tool below. Without backfill, the first
347
367
  edit of a pre-existing opp creates a *second* task.
@@ -422,6 +442,33 @@ None — platform-wide Forecast sync.
422
442
  (doubled-prefix ids — see the doubled-id gotcha), which are **SuiteQL-queryable** for after-the-fact
423
443
  diagnosis even though the calling-script log rolls off.
424
444
 
445
+ - **`logged()` could never insert a `Logs.Api` row — and it failed the whole feature SILENTLY.**
446
+ `Logs.Api` has NOT-NULL columns with **no defaults**. The `logged()` helper set
447
+ `direction`/`source`/`method`/`route` but never `isAuthRequest`, `instanceId` or `hostname`, so
448
+ **every** insert threw `Column 'X' cannot be null` and the exception propagated out of the wrapped
449
+ call. (`_ApiRequest`'s own auto-logging sets all three — `ApiRequest.php:260-264` — which is why
450
+ the omission is invisible when you compare against normal api-logged traffic.) The blast radius was
451
+ far wider than one failing cron: the NS→CU webhook path calls this inside
452
+ `try/catch(Throwable)`, so the whole TRUE-81010 label feature was a **permanent no-op that reported
453
+ success** — tasks created, both label fields empty, `(label lookup failed)` in the output, every
454
+ job green. **The identical omission existed in `_Worker_Clickup_Opportunity::logged()`**, where the
455
+ helper was ported from — fixed there too (PRs #150 + #151). Evidence it never once worked: since
456
+ 1 July `Logs.Api` held exactly two `source` values — `NULL` (1,413,654 rows, the `_ApiRequest`
457
+ auto-log path) and `OPEN_ORDER_IMPORT` (87,476). **Zero** `clickup-opportunity` rows, i.e. the
458
+ CU→NS sync's outbound logging has been dead since it shipped and nobody noticed. See
459
+ [`_ApiRequest`](../../_underscore/features/apirequest-json-content-type.md#gotchas--known-issues)
460
+ for the full manual-insert column contract.
461
+ - **When porting a helper, port its correctness too.** A faithful copy of broken code is still
462
+ broken, and a swallowed exception hides it indefinitely — that is exactly how one missing-column
463
+ bug ended up in two handlers and survived two weeks of green telemetry.
464
+ - **Do NOT reach for `_Component_Api_Clickup` for new work.** A live ClickUp API token is
465
+ **hardcoded in committed source** at `_underscore/Component/Api/Clickup/Clickup.php:5`, and because
466
+ `send()` never disables `_ApiRequest`'s auto-logging, that `Authorization` header has been
467
+ persisted into `Logs.Api.requestHeaders` in **plaintext on every call** (`ApiRequest.php:267`).
468
+ 11 worker2 files use the component. The TRUE-81010 work deliberately routed around it with an
469
+ inline `_ApiRequest`. **Needs its own ticket:** rotate the token and move it to config. (Never
470
+ paste the value anywhere, including here.)
471
+
425
472
  ## CU→NS direction (BUILT 2026-06-30, TRUE-79181): reverse sync
426
473
 
427
474
  The integration is now **bidirectional** for **field + stage**. ClickUp task edits flow back to the
@@ -502,6 +549,18 @@ deprecated** for production opportunity code.
502
549
  blocked on the Aaron stakeholder decision noted above.
503
550
 
504
551
  ## Change history
552
+ - 2026-08-26 — **Fixed `logged()` (PRs #150 + #151) and confirmed the atomic claim makes
553
+ `updateTask()` unreachable.** `logged()` omitted the NOT-NULL `isAuthRequest`/`instanceId`/
554
+ `hostname` columns, so every `Logs.Api` insert threw and — inside the webhook path's
555
+ `catch(Throwable)` — turned the whole TRUE-81010 label feature into a silent no-op that reported
556
+ success; the same omission was fixed in `_Worker_Clickup_Opportunity::logged()`, whose outbound
557
+ logging had been dead since it shipped (zero `clickup-opportunity` rows in `Logs.Api`). Also
558
+ recorded that `maybeCreateClickupTask()` claims `clickupTaskId` **before** consulting ClickUp
559
+ (`Opportunity.php:40`), so `updateTask()` is dead twice over — flipping `UPDATE_CLICKUP` activates
560
+ never-run code and still reaches none of the 761 existing tasks. Flagged the hardcoded ClickUp
561
+ token in `_underscore/Component/Api/Clickup/Clickup.php` (plaintext `Authorization` persisted to
562
+ `Logs.Api.requestHeaders`) for a dedicated security ticket. The Stakeholders/End Customer label
563
+ mapping moved to its own doc. (ajean)
505
564
  - 2026-08-19 — Recorded a second, worse instance of the "`Released` ≠ active" enqueuer-deployment failure
506
565
  family: the AMQ enqueuer is deployed **per record type**, and **Customer** (`customdeploy9`, deployment
507
566
  internal id 9) was still at `status = TESTING` / `isdeployed = F` while the other 15 types were
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-04
10
- owners: ["mhammontree", "jcardinal"]
9
+ updated: 2026-08-26
10
+ owners: ["mhammontree", "jcardinal", "ajean"]
11
11
  files:
12
12
  - worker2/Worker/Notification/Email.php
13
13
  - _underscore/Model/Client/EmailTemplate.php
@@ -140,10 +140,27 @@ working recipe — verified in classic + new Outlook:
140
140
  - **Missing wrapper row → unbranded send, logged.** If a client has no active wrapper row the alert
141
141
  still goes out (raw body) and an `error_log` line records the missing uuid. Branding is *not*
142
142
  guaranteed on this path by design (internal mail). Don't rely on it for client-facing email.
143
+ - **There is no attachment path on the worker email actions — send the payload INLINE.**
144
+ `_Worker_Notification_EmailTemplate::Send` has **no attachment parameter**, and
145
+ `_Email::addAttachment()` takes a **file path**, which an enqueued job running minutes later on a
146
+ different instance may no longer be able to find. A generated report (e.g. a CSV digest) should be
147
+ rendered into the body via `_Worker_Notification_Email::Send` rather than attached. Proven by
148
+ `Worker/Netsuite/OpportunityStakeholderDigest.php` — see
149
+ [ClickUp opportunity client labels](./netsuite-opportunity-client-labels.md).
150
+ - **Commit your own DB work BEFORE calling `Send()`.** `_Email` registers the client-log DB and
151
+ toggles the read host as a side effect of sending; doing that inside your still-open transaction
152
+ is asking for trouble. A worker that has been writing to, say, `DB_FORECAST` should
153
+ `_Database::transactionCommit(DB_FORECAST)` first, then send.
143
154
  - **`{subject}`/`{body}` substitution is `strtr`, not sequential `str_replace`.** All placeholders
144
155
  are replaced simultaneously, so a `{body}` literal inside the subject can't be re-expanded.
145
156
 
146
157
  ## Change history
158
+ - 2026-08-26 — Documented (no code change) that **neither worker email action can attach a file** —
159
+ `EmailTemplate::Send` has no attachment parameter and `_Email::addAttachment()` takes a file path
160
+ an enqueued job may not still have — so generated reports go **inline in the body**; and that a
161
+ caller must **commit its own transaction before `Send()`**, because `_Email` registers the
162
+ client-log DB and toggles the read host mid-send. Surfaced building the daily opportunity
163
+ stakeholder digest. (ajean)
147
164
  - 2026-08-04 (**uncommitted/undeployed** at time of writing) — Added an optional trailing
148
165
  `?int $priority = null` to `Send()`, mapping to `_Email::setPriority()`
149
166
  (`X-Priority`/`Importance`/`X-MSMail-Priority`). Backward compatible because the dispatcher
@@ -5,8 +5,8 @@ project: _Underscore
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-07-21
9
- owners: [jcardinal]
8
+ updated: 2026-08-26
9
+ owners: [jcardinal, apeterson]
10
10
  related:
11
11
  - ../apps/toga25-supply/workflows/amplify-deployment.md
12
12
  - ../apps/toga-blox/workflows/dynamic-publish-pipeline.md
@@ -50,7 +50,12 @@ strict 1:1). When an app does this:
50
50
  hardcoded per-branch list, so one `amplify.yml` serves every environment.
51
51
  - `--legacy-peer-deps` may be required when the app's and library's peer ranges diverge; when it
52
52
  is used, npm will not auto-install the library's peers, so any peer not already in the app's
53
- dependencies must be added explicitly.
53
+ dependencies must be added explicitly. **Set it in the app's committed `.npmrc`
54
+ (`legacy-peer-deps=true`), not only as a CLI flag in `amplify.yml`** — otherwise a developer's
55
+ local `npm i` fails `ERESOLVE` while CI succeeds. When one app installs the shared lib cleanly and
56
+ a sibling does not at the same lib and peer versions, **diff the two `.npmrc` files before
57
+ touching any peer range** (verified 2026-08-26: `toga25-supply` had the flag, `toga2-commerce` did
58
+ not; commerce's `.npmrc` fix is working-tree on `TRUE-80707`, not yet merged).
54
59
 
55
60
  ## Build memory / heap
56
61
 
@@ -5,8 +5,8 @@ project: _Underscore
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-08-18
9
- owners: [jcardinal]
8
+ updated: 2026-08-26
9
+ owners: [jcardinal, apeterson]
10
10
  files: []
11
11
  related:
12
12
  - ../apps/toga25-supply/architecture.md
@@ -352,6 +352,13 @@ memory.
352
352
  Supply dedupes the table lib (it consumes blox's table context); desk dedupes react-hook-form (it
353
353
  consumes blox's `BaseInput`, which reads `useFormContext()`). See §13(c) for why a second copy
354
354
  silently breaks blox's hooks.
355
+
356
+ Verified 2026-08-26: `toga2-commerce` had **no** `dedupe` at all and now declares
357
+ `["react","react-dom","react-hook-form","react-router-dom","framer-motion","@tanstack/react-query","@tanstack/react-table"]`
358
+ (working-tree on branch `TRUE-80707`, not yet merged). The concrete hazard: `toga-blox-npm/node_modules/`
359
+ ships its own `react@18.3.1`/`react-dom@18.3.1`, so consuming blox through a **local symlink**
360
+ resolves React to blox's own copy. Dedupe is what makes the symlink workflow safe — it is not
361
+ optional polish.
355
362
  - **MUST: one env-read chokepoint.** Read `VITE_API` in exactly **one** module (see §17). Verified:
356
363
  both apps read it (plus a per-host override) only in `src/api/api.ts`.
357
364
  - **SHOULD: ESLint flat config**, verified identical in both apps:
@@ -412,12 +419,18 @@ on the back end. A consuming app **MUST** meet every requirement below.
412
419
  **(a) Pin an exact published version — never a caret.** Some published versions ship an **empty
413
420
  `dist`** and break the build; a caret can float you onto one. Both apps pin exact versions
414
421
  (verified 2026-08-18: supply `1.0.330-sandbox-client.128`, desk `1.0.328-sandbox-client.125`).
422
+ Verified 2026-08-26: `toga2-commerce` violates this with `"^1.0.322-sandbox-client.117"`. Also audit
423
+ for **pin drift** between branches (commerce's `TRUE-80707` was 11 versions behind `_sandbox-client`).
415
424
 
416
425
  **(b) Declare blox's peer deps that do not hoist into your app.** blox lists them as peers, so npm
417
426
  will not necessarily install them for you: verified 2026-08-18 they include `react-hook-form`,
418
- `@tanstack/react-query`, `@tanstack/react-table`, `framer-motion`, `react-router-dom`, and `axios`
419
- (pinned exactly by blox at `1.8.4`). A missing peer surfaces as a runtime "undefined is not a
420
- component," not a build error.
427
+ `@tanstack/react-query`, `@tanstack/react-table`, `framer-motion`, and `axios` (pinned exactly by
428
+ blox at `1.8.4`). **`react-router-dom` was removed as a peer on 2026-08-26** — it was phantom (its
429
+ only production reference was a dead `Link` import in `old/BaseButton`); *(change staged on blox
430
+ `feature-new-table`, not yet merged/published — verify against the installed version before relying
431
+ on it)*. A missing peer surfaces as a runtime "undefined is not a component," **or**, because a
432
+ **barrel import pulls blox's whole index**, as a Rollup `failed to resolve import` for a component
433
+ you never referenced.
421
434
 
422
435
  **(c) Dedupe `react` / `react-dom` (+ `react-hook-form` / `@tanstack/react-table`) to a single
423
436
  copy.** blox is shipped **unbundled** — its `build` is `tsc && copyfiles … && fix-esm-imports`
@@ -575,7 +588,11 @@ Work **around** these shared-lib defects; do **not** imitate them in app code:
575
588
  - **Two select stacks** coexist (`react-select` + a custom `AdvancedSelect`) and **two table
576
589
  stacks** (`react-table` v7 + `@tanstack/react-table` v8) — verified 2026-08-18 in blox's peer
577
590
  deps. Know which one a given component uses before you wire it.
578
- - Phantom/unused peer deps in blox's manifest.
591
+ - **Phantom/unused peer deps in blox's manifest** — confirmed and one now removed:
592
+ `react-router-dom` (dead `Link` import in `old/BaseButton`; removal staged 2026-08-26 on
593
+ `feature-new-table`, not yet merged/published — it stays a devDependency for the tests'
594
+ `MemoryRouter`). Verify a declared peer is actually reachable in `src/` before adding it to your
595
+ app or reaching for `--legacy-peer-deps`.
579
596
  - Supply/commerce business logic has leaked into the shared lib.
580
597
  - **No custom-validator passthrough on `BaseInput`** — hence the app-side validator layer in §7.
581
598
  - A **~85-prop className-string styling contract on `BaseInput`** — verbose by design; drive it from
@@ -624,6 +641,11 @@ app work.** In a consuming app, guard against them and move on.
624
641
 
625
642
  ## Change history
626
643
 
644
+ - 2026-08-26 — Recorded commerce's missing `resolve.dedupe` (+ the blox-symlink second-React
645
+ hazard), its caret blox pin and cross-branch pin drift, the barrel-import missing-peer trigger,
646
+ and the removal of blox's phantom `react-router-dom` peer. All the underlying code changes are
647
+ working-tree only (blox `feature-new-table`, commerce `TRUE-80707`) — unmerged and unpublished.
648
+ (apeterson)
627
649
  - 2026-08-18 — Initial front-end (React) coding standard: universal React/TS layer + TOGA 2.0
628
650
  platform-conventions layer, mined from @agilant/toga-blox, toga25-supply, and toga25-desk; per-rule
629
651
  convergence tagging; testing/a11y/error-reporting/React-19 flagged as open. (jcardinal)
@@ -19,7 +19,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
19
19
  ## 2.0 framework
20
20
 
21
21
  - **_underscore** (_Underscore) _(framework core)_ — 67 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
- - **worker2** (Worker) — 56 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
22
+ - **worker2** (Worker) — 57 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 25 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 9 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
25
25
  - **toga2-supply** (TOGa Supply) — 9 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
@@ -31,7 +31,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
31
31
  - **ai-bdr** (AI-BDR) — 13 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
32
32
  - **toga2-commerce** (TOGa Commerce) — 19 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
33
33
  - **toga25-supply** (TOGa 2.5 Supply) — 12 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
34
- - **toga-blox** (TOGa Blox) — 10 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
34
+ - **toga-blox** (TOGa Blox) — 11 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
35
35
  - **bdr** (BDR) — 0 doc(s) → [2.0/apps/bdr/INDEX.md](2.0/apps/bdr/INDEX.md)
36
36
 
37
37
  ## standalone framework
@@ -6,8 +6,8 @@ project: Claude Harness
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-08-06
10
- owners: ["jcardinal", "tcox"]
9
+ updated: 2026-08-26
10
+ owners: ["jcardinal", "tcox", "ajean"]
11
11
  files:
12
12
  - claude/.claude/skills/kickoff/SKILL.md
13
13
  - claude/.claude/skills/capture/SKILL.md
@@ -138,6 +138,18 @@ data is missing. Query **`dev-sandbox`** whenever someone says "beta". Any front
138
138
  onboarding SQL shows beta on a us-west-2 `client-cluster`; that has since been consolidated —
139
139
  trust `Core.DatabaseHosts`, not old migrations.)
140
140
 
141
+ **⚠ The write-blocker is a naive string scan — it rejects `UPDATE`/`REPLACE` even inside a string
142
+ literal.** `toga_query` refuses the whole statement if those words appear anywhere in it, so a
143
+ perfectly read-only query like
144
+ `… WHERE output LIKE '%skipped update%'` fails validation. Work around it by matching on a fragment
145
+ that avoids the keyword (`LIKE '%skipped%'`), or by pulling the rows and filtering client-side —
146
+ don't conclude the data isn't there.
147
+
148
+ **Production sessions render in US/Central (UTC−5); most other timestamps you're comparing are
149
+ UTC.** GitHub/API/webhook timestamps are UTC, so a naive `dtStamp` vs. commit-time comparison is off
150
+ by five hours — enough to make a healthy worker fleet look dead. Anchor both sides to UTC (or
151
+ `CONVERT_TZ`) before drawing a conclusion about "when did this last run".
152
+
141
153
  **`local` is not this MCP.** If someone means their own machine's database, use the `mysql` CLI
142
154
  via shell, not `toga-db`.
143
155
 
@@ -183,5 +195,10 @@ Schema names are **case-sensitive** — copy the exact value from `toga_list_sch
183
195
  and why. Database → what is actually true right now.
184
196
 
185
197
  ## Change history
198
+ - 2026-08-26 — Added two `toga-db` query traps: the write-blocker is a **naive string scan** that
199
+ rejects any statement containing `UPDATE`/`REPLACE` **even inside a string literal** (so
200
+ `LIKE '%skipped update%'` fails validation on a read-only SELECT), and **production DB sessions
201
+ render in US/Central (UTC−5)** while GitHub/API/webhook timestamps are UTC — the mismatch made a
202
+ healthy worker fleet look dead. (ajean)
186
203
  - 2026-08-06 — Recorded that **"beta" resolves to the `dev-sandbox` cluster** for client databases (`Core.DatabaseHosts` env `beta` → the sandbox-dev host) while the MCP's `client-beta` environment exposes no schemas — query `dev-sandbox` when someone says beta. Cost real debugging time while onboarding Elite to the supply2 frontend (tcox)
187
204
  - 2026-07-30 — Created. Established that "ask/use Talos" means the Internal Knowledge Base MCP and not the `talos` repo; that Claude runs its own SELECTs unprompted while developers execute writes; and that both MCP connections are used proactively without being requested. Recorded the KB↔registry client-name divergence and the `Core.Clients` identifier traps (jcardinal)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.654",
3
+ "version": "1.0.655",
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",