toga-ai 1.0.997 → 1.0.999

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.
@@ -64,6 +64,6 @@
64
64
  | [Users.email is a login identity — the forgot-password chain and tenant routing that depend on it](features/user-email-as-identity-and-password-reset.md) | Why `Users.email` in 2.0 is a login identity + tenant-routing key (forgot-password takeover primitive, domain routing); open before building any UI/job that edi |
65
65
  | [USPS DPV Deliverability Verdict (is this address actually insurable/shippable?)](features/usps-dpv-deliverability.md) | How USPS DPV (Delivery Point Validation) codes decide whether an address is deliverable, and the platform Y/D/S/N mapping; open when validating an address or wh |
66
66
  | [Refreshing a Local Dev Database from Beta (dev-sandbox)](workflows/local-db-refresh-from-beta.md) | Reset a local 2.0 dev DB from the beta/dev-sandbox host; open when a local schema is stale/broken, or after pulling `_underscore` past a migration the local sch |
67
- | [Deleting a shared branch does not remove bad commits — a stale local clone merges them back](workflows/recreated-shared-branch-stale-local-remerge.md) | Why a bug "fixed" by deleting/recreating a shared branch returns; open when asking "we already fixed this — why is it back?" **Deleting and recreating a shared |
67
+ | [Deleting a shared branch does not remove bad commits — a stale local clone merges them back](workflows/recreated-shared-branch-stale-local-remerge.md) | Why a bug "fixed" by deleting/recreating/force-pushing a shared branch returns, and why a merged fix can vanish from one; open when asking "we already fixed thi |
68
68
  | [Rotating Public TLS Certificates (ACM, CloudFront, ALB, Elastic Beanstalk, API Gateway)](workflows/rotating-public-tls-certificates.md) | How to replace the public TLS certificates that terminate HTTPS for TOGa front-end domains (togahub, togacommerce, togadesk, togaretail, togasupply, togaview, t |
69
69
  | [Running a 2.0 App Locally (browser, end-to-end via api2)](workflows/running-a-2.0-app-locally.md) | Full dependency chain to run a 2.0 client app through the browser against a local api2; open when standing up the local web stack or debugging a local-only 500. |
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: [apeterson]
9
+ updated: 2026-10-08
10
+ owners: [apeterson, tcox]
11
11
  files:
12
12
  - _underscore/Model/Core/Surface.php
13
13
  - _underscore/Model/Client/RecordFieldSetting.php
@@ -20,7 +20,7 @@ related:
20
20
  - ../../dbchanges2/workflows/framework-branch-ahead-of-schema.md
21
21
  ---
22
22
 
23
- Why a bug "fixed" by deleting/recreating a shared branch returns; open when asking "we already fixed this — why is it back?"
23
+ Why a bug "fixed" by deleting/recreating/force-pushing a shared branch returns, and why a merged fix can vanish from one; open when asking "we already fixed this — why is it back?" or "my fix doesn't work on sandbox".
24
24
 
25
25
  ## Summary
26
26
 
@@ -48,6 +48,19 @@ The recreated branch looks fixed, and genuinely is, for as long as it takes one
48
48
 
49
49
  **Why branch deletion could never have worked here:** `a5e79448` — the commit introducing the whole affected code path — is **also** reachable from `origin/toga25-desk` and `origin/_qa-alpha`, and `toga25-desk` is merged into `_sandbox-client` routinely (e.g. `8bca0286`, 2026-08-27). Even with every stale local clone deleted, the code path returns on the next `toga25-desk` merge. **Deleting a ref cannot remove a commit another branch reaches** — the structural reason to fix forward, not to keep doing branch surgery.
50
50
 
51
+ **Same mechanism, other direction — a merged fix disappears (`toga2-commerce` / `_sandbox-client`, 2026-10-07).** `_sandbox-client` was force-pushed ~16:21 to `fdbdcfde` (PR #374, TRUE-82423), dropping earlier merges incl. TRUE-81382 (`3054a1e8`, `0f2076a1`, `c50bd952`). Symptom: "my fix doesn't work on sandbox" — the fix was simply not on the branch any more.
52
+
53
+ ## Steps — "my fix doesn't work on the shared branch"
54
+
55
+ 1. Is the fix still on the remote branch?
56
+ ```
57
+ git fetch origin
58
+ git merge-base --is-ancestor <fix-commit> origin/<branch> && echo ON || echo GONE
59
+ ```
60
+ 2. Was the branch rewritten? `git reflog show origin/<branch>` — a `forced-update` line marks the rewrite.
61
+ 3. **Do NOT push your local copy** — it still holds the old remote history and re-pollutes the branch (rule 2 below).
62
+ 4. Delete the local branch, recreate it tracking `origin/<branch>`, re-merge only your ticket branch, then push.
63
+
51
64
  ## Steps — triaging "we already fixed this, why is it back?"
52
65
 
53
66
  1. **Read the merge parents of the reconcile merge.** Names the vector precisely, one command:
@@ -6,7 +6,7 @@ project: TOGa Commerce
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-01
9
+ updated: 2026-10-08
10
10
  owners: ["bala", "apeterson", "tcox"]
11
11
  files:
12
12
  - toga2-commerce/src/lib/queryClient.ts
@@ -24,6 +24,7 @@ files:
24
24
  - toga2-commerce/src/pages/Cart/viewModel/useEditAddressFromCartForm.ts
25
25
  - toga2-commerce/src/queries/queries.ts
26
26
  - toga2-commerce/src/hooks/useDeploymentGuard.tsx
27
+ - toga2-commerce/src/stores/useBundleBuilderZu.ts
27
28
  related:
28
29
  - ./inactive-item-purchase-gating.md
29
30
  - ./force-logout-on-deployment.md
@@ -78,7 +79,14 @@ const shouldDehydrateQuery = (query) => {
78
79
  // … persistOptions: { dehydrateOptions: { shouldDehydrateQuery } }
79
80
  ```
80
81
 
81
- > **⚠ CORRECTION (verified 2026-09-03): this exclusion is NOT in production.** `shouldDehydrateQuery` exists only on `origin/_beta` and `origin/#sprint84`. `origin/_production`, `origin/_sandbox`, `origin/_dev` and `TRUE-81610` all pass `persistOptions: { persister: localStoragePersister }` with **no `dehydrateOptions` at all** — so **nothing is excluded from persistence in production**, and the inactive-item purchase gate it was built for is not actually protected there. Confirm the branch before relying on this section.
82
+ > **⚠ Branch state (verified 2026-10-08) — two different persistence filters exist; neither is on `_production`.**
83
+ > - `_production`: `buster` + `throttleTime: 0`, **no `dehydrateOptions`** — nothing is excluded from persistence.
84
+ > - `_beta` / `#sprint84`: the `NON_PERSISTED_QUERY_KEYS` filter above, in `App.tsx`.
85
+ > - `TRUE-81382` / `_sandbox-client`: `shouldPersistQuery` in `src/queries/queries.ts`, excluding `BUNDLE_QUERY_KEYS` (`fetchBundlesByPersona`, `bundleData`, `bundleImagesData`, `fetchItemDetailsViaBundle`).
86
+ >
87
+ > When these meet, merge into **one** filter holding both key lists. Confirm the branch before relying on this section.
88
+
89
+ **Bundle queries are uncached (TRUE-81382).** `bundleData`, `bundleImagesData`, `fetchBundlesByPersona` spread `BUNDLE_NO_CACHE_QUERY_OPTIONS` (`{ staleTime: 0, gcTime: 0 }`, `src/queries/queries.ts`) and are excluded by `shouldPersistQuery`. `useBundleBuilderZu` no longer persists the raw parent-bundle API payload. Cause: an inactive item was orderable from a stale persisted bundle. Merging with `e546db3d` (TRUE-81991): in `App.tsx` keep **both** `buster` and `dehydrateOptions`; in the bundle queries keep `BUNDLE_NO_CACHE_QUERY_OPTIONS` + `refetchOnWindowFocus: false` and drop the 30s/1h values.
82
90
 
83
91
  **Why the exclusion is required:** `gcTime: 0` only evicts a query once it becomes **inactive**. An **active** query — exactly the state the item page is in while the user is looking at it — is still dehydrated and written to `localStorage`, and is rehydrated on the next load. So `gcTime: 0` alone leaves the stale answer on disk.
84
92
 
@@ -126,7 +134,7 @@ Inventory verified read-only on 2026-10-01. Every value below is a **per-query o
126
134
  |---|---|
127
135
  | **24h** | `pages/ItemsView/viewModel/useItemDetailsViewModel.ts:76` (item detail) · `pages/Filter/viewModel/useFilterViewModel.ts:106,134` (filter/search results) · `pages/Cart/viewModel/useCartViewModel.ts:310` (shipping methods) · `pages/Cart/viewModel/useEditAddressFromCartForm.ts:75` |
128
136
  | **5 min** | `queries/queries.ts:39,46,68,78` (login / prefetch) |
129
- | **30s** (+ `gcTime: 1h`) | `pages/Home/viewModel/useHomeViewModel.ts:150,175,201` · `pages/BundleView/hooks/useBundleQueryData.ts:26,42` |
137
+ | **30s** (+ `gcTime: 1h`) | `pages/Home/viewModel/useHomeViewModel.ts:150,175,201` · `pages/BundleView/hooks/useBundleQueryData.ts:26,42` — the **bundle** queries among these become `0`/`0` with TRUE-81382 (above) |
130
138
 
131
139
  **Consequence — the answer to "why doesn't a price change show up":** a price or availability edit can take **up to 24 hours** to reach a shopper on the item page, and **a browser refresh does not fix it**, because the cache is persisted to `localStorage["commerce"]` and survives reload and browser restart.
132
140
 
@@ -180,15 +188,16 @@ Keep the **app default at `0`**, like supply.
180
188
  - **⚠ A cart line's price never refetches.** The cart is Zustand + `localStorage`, outside React Query; no `staleTime` change touches it.
181
189
  - **⚠ Never verify a catalogue or FIELDS change on a warm browser.** Clear `localStorage.removeItem('commerce')` or log out first. A "it didn't deploy" report is usually a rehydrated cache.
182
190
  - **⚠ `gcTime: 0` does not stop persistence.** Active queries are still dehydrated. Exclude the key via `dehydrateOptions.shouldDehydrateQuery` as well.
183
- - **⚠ Nothing is excluded from persistence on `_production`** — the `shouldDehydrateQuery` allowlist lives only on `_beta`/`#sprint84`. Check the branch.
191
+ - **⚠ Nothing is excluded from persistence on `_production`** (2026-10-08) — the two filters live on `_beta` and on `TRUE-81382`/`_sandbox-client`. Check the branch.
184
192
  - **⚠ The core-parameters watcher is not a targeted invalidation** — any Core parameter change force-logs-out every user on the environment. Use per-query opt-out or `queryClient.invalidateQueries` for anything narrower.
185
193
  - **⚠ A `queryClient.clear()` that is not awaited behind cache restoration is undone.** Always clear via `clearPersistedQueryCache()`; never call `queryClient.clear()` directly. Cancelling in-flight queries is part of the clear, not an extra.
186
194
  - **⚠ A persister `throttleTime` above 0 can write back after a clear.** Keep it at `0`.
187
195
  - **⚠ Gate auth on `user`, never `accessToken`** — `fetchPublicToken` writes a public token into `accessToken`. See [SSO redirect & session gating](./sso-redirect-and-session-gating.md).
188
- - **A `persistOptions.buster` keyed on the build version** would fix stale data at deploy time. Commerce has a committed `amplify.yml`, so injecting `AWS_COMMIT_ID` as a build id is straightforward here (`package.json` `version` is `0.0.0` and `vite.config.ts` has no `define` block, so the id must be created first). Mechanics and the scope limit — a buster runs inside the app and can never fix a browser-cached JS bundle — are written up in [supply's persisted query cache](../../toga25-supply/features/persisted-query-cache.md#the-buster-gap). Still **not implemented**.
196
+ - **`persistOptions.buster = __BUILD_ID__` is live on `_production`** (`e546db3d`): a new build drops the persisted cache on restore instead of hydrating it. It runs inside the app, so it cannot fix a browser-cached JS bundle — see [supply's persisted query cache](../../toga25-supply/features/persisted-query-cache.md#the-buster-gap).
189
197
  - **`AuthLayout` never remounts**, so `refetchOnMount` fires once per session for queries it owns — a route-change refresh has to be wired explicitly. That interacts with everything above: a query can be both stale *and* never refetched during a session.
190
198
 
191
199
  ## Change history
200
+ - 2026-10-08 — Bundle queries uncached + excluded from persistence (TRUE-81382); branch map of the two persistence filters; buster marked live on `_production`. (tcox)
192
201
  - 2026-10-01 — Corrected: the global default is already `staleTime: 0` (`QueryClient` moved to `src/lib/queryClient.ts`, `refetchOnWindowFocus: false`, persister `buster` added). Per-query 24h overrides inventoried at `file:line`; ticket scope and the post-change freshness promise (and its cart / alt-tab / first-paint limits) recorded. No code changed. (apeterson)
193
202
  - 2026-09-22 — Causes 1 and 2 fixed: app default `staleTime: 0` + `refetchOnWindowFocus: false`, heavy hooks on 30s/1h, and all clears routed through `clearPersistedQueryCache()` with `throttleTime: 0`. Login now resets client state. TRUE-81991 (`e546db3d`). (apeterson)
194
203
  - 2026-09-21 — Cause 3 (public-token auth gate) fixed on `TRUE-82172` and moved to its own doc; causes 1, 2, 4 still open. (tcox)
@@ -6,8 +6,8 @@ project: TOGa Commerce
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: ["bala"]
9
+ updated: 2026-10-08
10
+ owners: ["bala", "tcox"]
11
11
  files:
12
12
  - toga2-commerce/src/utils/checkIsItemPurchasable.ts
13
13
  - toga2-commerce/src/api/fetchItemsActiveStatus.ts
@@ -18,6 +18,9 @@ files:
18
18
  - toga2-commerce/src/pages/Cart/CartPage.tsx
19
19
  - toga2-commerce/src/pages/Cart/view/cartTable/CartTableBundleItem.tsx
20
20
  - toga2-commerce/src/api/syncSalesOrderFromApiToLocalStorage.ts
21
+ - toga2-commerce/src/pages/OrderDetails/hooks/useSubmitOrder.ts
22
+ - toga2-commerce/src/pages/OrderDetails/helpers/findUnavailableCartItems.ts
23
+ - toga2-commerce/src/pages/OrderDetails/api/OrderDetailsApi.ts
21
24
  - _underscore/Model/Compass/SalesOrder.php
22
25
  - dbchanges2/Client_Compass/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql
23
26
  - dbchanges2/Client_CompassCanada/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql
@@ -33,7 +36,7 @@ related:
33
36
  - ../../../clients/compass-usa/workflows/changing-a-kit-line-item.md
34
37
  ---
35
38
 
36
- `Items.isActive = 0` blocks standalone purchase but means something different on kit components, so the gate keys on purchase CONTEXT not the flag; open when touching item purchasability, cart reconciliation, or the order-write interceptor.
39
+ `Items.isActive = 0` blocks standalone purchase but means something different on kit components, so the gate keys on purchase CONTEXT not the flag; open when touching item purchasability, cart reconciliation, the submit-time availability re-check, or the order-write interceptor.
37
40
 
38
41
  ## Summary
39
42
 
@@ -53,9 +56,11 @@ An item with **`Items.isActive = 0`** must not be viewable, addable to a cart, o
53
56
 
54
57
  **Therefore the gate is on the purchase CONTEXT, never on the flag alone.** The discriminator is whether the line belongs to a kit: `isset($payload->bundleItem)` server-side, the bundle wrapper client-side. Kit lines are never evaluated.
55
58
 
59
+ > **⚠ Branch state (verified 2026-10-08): two separate implementations exist.** Layers 1-4 (`checkIsItemPurchasable`, `useCartReconciliation`) are on **`_beta` only** — not `_production`, not `_sandbox-client`. Layer 6 (TRUE-81382 submit re-check) was built on `_production`'s base and is on `TRUE-81382` / `_sandbox-client`. They **disagree on kit lines** (first gotcha). Reconcile them before both reach `_production`.
60
+
56
61
  ## How it works
57
62
 
58
- Five layers, front to back. Each one is independently sufficient for its own entry point; together they close the paths a single check would leave open.
63
+ Six layers, front to back. Each one is independently sufficient for its own entry point; together they close the paths a single check would leave open.
59
64
 
60
65
  | # | Layer | Where |
61
66
  |---|---|---|
@@ -64,6 +69,7 @@ Five layers, front to back. Each one is independently sufficient for its own ent
64
69
  | 3 | Guard inside the cart store's `addItem` | `src/stores/useCartStoreZu.ts` |
65
70
  | 4 | Cart reconciliation against the live catalog | `useCartReconciliation.ts`, `CartPage.tsx` |
66
71
  | 5 | Server-side PRE interceptor on the order write | `_Model_Compass_SalesOrder::prePost` / `::prePut` |
72
+ | 6 | Submit-time re-fetch of every cart line (TRUE-81382) | `useSubmitOrder.ts`, `helpers/findUnavailableCartItems.ts` |
67
73
 
68
74
  ### 1–2. The helper fails CLOSED, and that is the whole point
69
75
 
@@ -93,6 +99,18 @@ Registered by `dbchanges2/Client_{Compass,CompassCanada}/2026-08-14a - AddSalesO
93
99
  - **Server:** the check returns early unless `$api->app->slug === 'commerce'`. Supply, NetSuite, EDI and admin corrections must still be able to write lines referencing deactivated items. It uses the **slug**, not `Apps.id`, because **only `Core.Records` and `Core.RecordFields` carry team-maintained ids** — `Apps.id` differs per environment, so an id literal would silently scope to the wrong app somewhere.
94
100
  - **Front end:** the new list-form item fetch and the unavailable panel are gated on the **`getItemsByPersona.isEnabled` FIELDS flag**, not on a client name. QUAD is the only tenant with it enabled, and QUAD keeps the old single-uuid persona-filtered path **byte-for-byte unchanged**. A future tenant opts in by *not* enabling that flag, with no code change.
95
101
 
102
+ ### 6. Submit-time re-check (TRUE-81382)
103
+
104
+ Second guard for an item switched off while **already in the cart** (item/bundle pages already show "Item Not Available" on the add button). Runs only on a **new** cart submit (`viewTypeParam === "cart"`); edit-order is skipped (next section — an old deactivated line would block every edit, even an address change).
105
+
106
+ - **Standalone lines:** one `/items` list fetch by uuid **with no `isActive` filter on purpose** — inactive rows must come back to be judged.
107
+ - **Bundles:** `GET /bundles/<uuid>` per bundle with `bundleItems.isActive`, `bundleItems.item.isActive`, nested `childBundle` fields. Unavailable = bundle, bundle item, its item, or child bundle is off **or missing** (404/410 bundle = gone).
108
+ - **Block:** error toaster `These items are no longer available. Remove them from your cart to continue:` + one `Title (partNumber)` per line.
109
+ - **Fails closed:** check still throws after retry → toaster `We could not confirm your items are still available. Please try again.` + Sentry capture; no submit.
110
+ - **A missing flag is NOT inactive** (`isFlagOff` accepts only `false`/`0`/`"0"`/`"false"`). Opposite of layer 2's rule — layer 2 asks "is it purchasable?", layer 6 asks "was it switched off?".
111
+
112
+ Test (verified sandbox-client 2026-10-08): add a bundle with an active item → set `Items.isActive = 0` → submit **without refreshing** → toaster. Used `Client_Compass.Items.id 795` (`13400---ODP`) in bundle "HP Light Duty Color L/J Printer".
113
+
96
114
  ### Editing a placed order KEEPS an already-inactive line
97
115
 
98
116
  Only a **newly added** inactive item is refused. An order that already contains a line whose item has since been deactivated must still be editable.
@@ -106,6 +124,7 @@ Implementation:
106
124
 
107
125
  ## Gotchas
108
126
 
127
+ - **⚠ OPEN CONFLICT — layer 6 judges kit components; this doc says that must never happen.** `findUnavailableCartItems` blocks a kit line when `bundleItems.item.isActive` is off. Most Compass kit heroes are inactive **on purpose** (Summary table: 87 of 91 primaries on dev-sandbox), so on that data layer 6 would block nearly every kit order. Before TRUE-81382 reaches `_production`: check real kit data, and likely judge kit lines only on `bundleItems.isActive` + bundle / child-bundle flags, not the component item's own flag.
109
128
  - **⚠ `Items.isActive = 0` means two different things and nothing distinguishes them.** Never gate on the flag alone. Only a **standalone** line is judged; the kit-membership check (`bundleItem` present / absent) is what makes the flag safe to read.
110
129
  - **⚠ A failed lookup must never empty a cart.** `fetchItemsActiveStatus` originally returned `[]` when `data.items` was missing or not an array. Reconciliation reads an empty list as *"every item I asked about is gone"* and clears the cart — so a malformed `200` walked straight past the `try`/`catch` that was supposed to guarantee a lookup failure was harmless. It now **throws**, so the caller's `catch` leaves the cart untouched; a genuine zero-row answer still returns a real empty array. Any "reconcile against the server" routine needs this distinction between *empty* and *unknown*.
111
130
  - **⚠ Judge only the uuids you actually queried.** The reconcile `set` filtered the *current* `state.items` against results computed from a snapshot taken **before** the `await`, so a line added while the lookup was in flight was removed without ever being checked. Fixed by scoping the removal to the queried uuid set.
@@ -115,6 +134,9 @@ Implementation:
115
134
  - **⚠ Do not resolve a hero (or any kit line) by `partNumber` alone.** `Client_Compass.Items` can hold the same part number twice with opposite flags — `MD7F4LL/A-S` is id **2760** (`title` set, `isActive = 0`, the row the kits actually reference) and id **2784** (`title` NULL, `isActive = 1`, unused). A part-number lookup can land on the twin that no kit uses, and the two disagree about `isActive`. Match on `BundleItems.itemId`.
116
135
  - **The server check is one query per order, not per line** — uuids are deduped into a single `IN (…)` lookup, and the empty list returns early because `IN ()` is a MySQL syntax error.
117
136
 
137
+ ## Change history
138
+ - 2026-10-08 — Added layer 6 (TRUE-81382 submit re-check), branch state, and the open kit-component conflict. (tcox)
139
+
118
140
  ## Related
119
141
 
120
142
  - [catalog-cache-freshness](./catalog-cache-freshness.md)
@@ -6,8 +6,8 @@ project: TOGa Commerce
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-09-25
10
- owners: [apeterson]
9
+ updated: 2026-10-08
10
+ owners: [apeterson, tcox]
11
11
  files:
12
12
  - toga2-commerce/vite.config.ts
13
13
  - toga2-commerce/package.json
@@ -32,6 +32,8 @@ The tenant is resolved from `window.location.hostname`, and api2 checks the requ
32
32
 
33
33
  - **⚠ `npm run sandbox-client` dies with `getaddrinfo ENOTFOUND`** until `client.sandbox.togacommerce` has its own hosts entry.
34
34
  - **⚠ A missing hosts entry looks like an auth bug.** The symptom is an instant redirect to `/login`, not a network error.
35
+ - **⚠ `npm run dev` talks to beta, not sandbox-client.** `.env.development` sets `VITE_API = https://api.beta.togahub.com/v2`. A DB error seen locally (e.g. 1054 `Unknown column 'isOpen'` on `SalesOrderItems`, 2026-10-08) is beta's schema — check the request host before reading another environment's DB or `Logs_<Tenant>.Api`.
35
36
 
36
37
  ## Change history
38
+ - 2026-10-08 — Added: local dev API target is beta. (tcox)
37
39
  - 2026-09-25 — Documented after tracing "cannot sign in locally" to the Origin/`Core.Domains` check. (apeterson)
@@ -3,3 +3,5 @@
3
3
  | Doc | Summary |
4
4
  |-----|---------|
5
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
+ | [Old blox (toga-blox) straight port inside toga25-blox](features/old-blox-port.md) | Old `@agilant/toga-blox` code copied unchanged into toga25-blox (blox-v1 layer); open before touching any old component, its checks/config, or merging Storybook |
7
+ | [Moving an old app from @agilant/toga-blox onto toga25-blox](workflows/moving-an-old-app-onto-toga25-blox.md) | Planned steps to switch an old app off the `@agilant/toga-blox` npm package onto toga25-blox source with zero import edits; open before moving any old app. |
@@ -0,0 +1,69 @@
1
+ ---
2
+ title: Old blox (toga-blox) straight port inside toga25-blox
3
+ framework: "2.0"
4
+ repo: toga25-blox
5
+ project: TOGa Blox 2.5
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-10-08
10
+ owners: [tcox]
11
+ files:
12
+ - toga25-blox/src/index.ts
13
+ - toga25-blox/src/components
14
+ - toga25-blox/assets
15
+ - toga25-blox/tsconfig.blox-v1.json
16
+ - toga25-blox/vitest.blox-v1.config.ts
17
+ - toga25-blox/types/fontawesome-pro.d.ts
18
+ - toga25-blox/package.json
19
+ related:
20
+ - ../architecture.md
21
+ - ../workflows/moving-an-old-app-onto-toga25-blox.md
22
+ - ../../toga-blox/architecture.md
23
+ ---
24
+ Old `@agilant/toga-blox` code copied unchanged into toga25-blox (blox-v1 layer); open before touching any old component, its checks/config, or merging Storybook onto it.
25
+
26
+ ## Summary
27
+
28
+ Old blox moves to toga25-blox as a **straight port, not a rewrite** (tcox, 2026-10-07). Only the delivery changes (git source, no npm); the code and its public names do not. Apps must keep compiling with zero import edits.
29
+
30
+ **Rules:**
31
+ - Copy old code unchanged. Never rename exports, props, signatures, or return types — old apps across the org import them. New names are additive only.
32
+ - No `legacy/` folder. Old code keeps its old layout.
33
+ - Do not fix old bugs during the port; fix them later as separate, additive changes.
34
+
35
+ A first attempt that rewrote Tooltip/Tabs/Avatar/InlineAlert in new-blox style with renamed props was thrown away for breaking these rules.
36
+
37
+ ## How it works
38
+
39
+ ### Layout
40
+ - Source = `toga-blox-npm` **`origin/_production`** (the local `_sandbox-client` checkout differed from origin in 18 files — do not copy from a local branch).
41
+ - Copied to the **top of `toga25-blox/src`** in the old layout: `src/index.ts` (old root barrel), `components`, `utils`, `api`, `hooks`, `templates`, `reactQuery`, `hoc`, `userHoc`, `global.css`, `main.css`, `setupTests.ts`, `global.d.ts`. New entries (`lib`, `core`, … `data-table`) sit beside them, untouched.
42
+ - Old `assets/` lives at the **repo root**, so old `../../../../assets/...` imports still resolve.
43
+ - `*.stories.*` not copied.
44
+ - Name clashes (old vs new `Badge`, `Toaster`, `MultiSelect`, `useTableData`) are avoided by entry: old ones only behind the old root barrel, new ones behind `@toga/blox/<entry>`.
45
+ - `src/components/old/` is old blox's own folder of older versions (Badge, BaseButton, BaseInput, Header, Toaster, ToggleButton). Not exported, no app imports it, but Card templates, CounterButton, GetSupport, InfoBanner, MobileMenu import it internally — keep it.
46
+
47
+ ### Checks and config (old code is not held to new-blox rules)
48
+ - **TypeScript:** `tsconfig.json` excludes the old folders. `tsconfig.blox-v1.json` = old repo compiler settings (strict off, tests excluded) + `types/`. `npm run typecheck` runs both.
49
+ - **ESLint:** `eslint.config.js` ignores the old folders.
50
+ - **Tests:** `vitest.config.ts` excludes them. `vitest.blox-v1.config.ts` (globals + jest-dom, FA Pro aliased to `@fortawesome/free-solid-svg-icons`) runs via `npm run test:blox-v1` — **not** part of `npm test`. 13 old test files already fail in the old repo (stale: `dist/main.css` imports, wrong paths, old props); keeping them out keeps blox-gate CI green.
51
+ - **Dependencies:** old packages are **devDependencies only, never peers** — blox peers feed `defineTogaApp` dedupe in desk/iq/template Vite builds. Apps install them themselves (see the workflow). `uuid@^9` added explicitly — old blox imported it without declaring it.
52
+ - **FontAwesome Pro is not installed in blox.** blox-gate CI runs `npm ci` with no tokens by design. `types/fontawesome-pro.d.ts` stubs types for `src/utils/getFontAwesomeIcon.tsx`; apps install Pro.
53
+ - No `.npmrc`, no publish/`dist` config; every lockfile package resolves from public `registry.npmjs.org` — no tie to the old npm subscription.
54
+ - `npm run inventory` lists the old folders as entries; undocumented old exports are a warning only.
55
+
56
+ ## Gotchas
57
+
58
+ - **Old `Header` (`src/components/old/Header`) renders two hamburger buttons** when `mobileLeft` and `mobileRight` are both true (git history shows it was `mobileRight ? right : left`). Ported as-is, not fixed.
59
+ - **The port is a snapshot of `origin/_production` taken 2026-10-07.** A fix merged into `toga-blox-npm` after that must also be applied here, or the copies drift.
60
+ - **Merging Storybook (branch TRUE-82044) onto the port (TRUE-81994):** expect conflicts in `package.json`, `tsconfig.json`, `scripts/inventory/inventory.mjs` (inventory.json output); regenerate `package-lock.json`. Stories for old components must import from `src/index.ts` and need Tailwind plus the FA Pro→free alias. Do not commit `storybook-static/` — build it in CI.
61
+
62
+ ## Related
63
+
64
+ - [toga25-blox architecture](../architecture.md)
65
+ - [Moving an old app onto toga25-blox](../workflows/moving-an-old-app-onto-toga25-blox.md)
66
+ - [toga-blox architecture](../../toga-blox/architecture.md) — the source library.
67
+
68
+ ## Change history
69
+ - 2026-10-08 — Port landed on branch TRUE-81994: old src copied as-is, separate blox-v1 typecheck/test configs, old packages as devDependencies (tcox)
@@ -0,0 +1,44 @@
1
+ ---
2
+ title: Moving an old app from @agilant/toga-blox onto toga25-blox
3
+ framework: "2.0"
4
+ repo: toga25-blox
5
+ project: TOGa Blox 2.5
6
+ client: shared
7
+ type: workflow
8
+ status: draft
9
+ updated: 2026-10-08
10
+ owners: [tcox]
11
+ files:
12
+ - toga25-blox/src/index.ts
13
+ related:
14
+ - ../features/old-blox-port.md
15
+ - ../architecture.md
16
+ - ../../toga-blox/workflows/local-link-into-a-consumer-app.md
17
+ ---
18
+ Planned steps to switch an old app off the `@agilant/toga-blox` npm package onto toga25-blox source with zero import edits; open before moving any old app. Not yet done for any app.
19
+
20
+ ## Summary
21
+
22
+ Old-blox apps: toga25-supply, toga2-hub, toga2-view, toga2-commerce, toga2-supply, BDR, talos, togatech. Each switches by **aliasing** the old package name to the ported source ([old-blox-port](../features/old-blox-port.md)) — app code and imports do not change. Status: planned; no app moved yet — confirm each step on the first app and update this doc.
23
+
24
+ ## Steps
25
+
26
+ 1. Alias `@agilant/toga-blox` → `../toga25-blox/src/index.ts` in the app's Vite config and tsconfig `paths`.
27
+ 2. Alias `@agilant/toga-blox/dist/*` → `../toga25-blox/src/*` (covers ~40 deep `dist/components/...` imports).
28
+ 3. Add to the app's own `package.json` the packages old blox used to ship as its dependencies: framer-motion, react-select, react-multi-select-component, @tanstack/react-table, @tanstack/react-virtual, react-table, dayjs, class-variance-authority, classnames, tailwind-merge, uuid, @sentry/react, react-router-dom, FontAwesome (incl. Pro — blox does not install Pro).
29
+ 4. Dedupe `react` / `react-dom` and shared peers in the Vite config.
30
+ 5. Amplify build: clone `toga25-blox` instead of `npm install @agilant/toga-blox@$MODE`.
31
+ 6. Remove `@agilant/toga-blox` from `package.json`.
32
+ 7. toga2-supply only: point the Tailwind `content` glob at `../toga25-blox/src`.
33
+
34
+ ## Gotchas
35
+
36
+ - **BDR and talos are Next.js / React 19** — the Vite alias steps do not apply as written. Work out the Next alias setup separately.
37
+
38
+ ## Related
39
+
40
+ - [Old blox port](../features/old-blox-port.md)
41
+ - [Local-linking blox into a consumer](../../toga-blox/workflows/local-link-into-a-consumer-app.md) — dedupe and React 19 lessons.
42
+
43
+ ## Change history
44
+ - 2026-10-08 — Plan written; no app moved yet (tcox)
@@ -32,7 +32,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
32
32
  - **toga25-supply** (TOGa 2.5 Supply) — 23 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
33
33
  - **toga25-iq** (TOGa IQ) — 1 doc(s) → [2.0/apps/toga25-iq/INDEX.md](2.0/apps/toga25-iq/INDEX.md)
34
34
  - **toga25-desk** (TOGa Desk 2.5) — 1 doc(s) → [2.0/apps/toga25-desk/INDEX.md](2.0/apps/toga25-desk/INDEX.md)
35
- - **toga25-blox** (TOGa Blox 2.5) — 1 doc(s) → [2.0/apps/toga25-blox/INDEX.md](2.0/apps/toga25-blox/INDEX.md)
35
+ - **toga25-blox** (TOGa Blox 2.5) — 3 doc(s) → [2.0/apps/toga25-blox/INDEX.md](2.0/apps/toga25-blox/INDEX.md)
36
36
  - **toga-blox** (TOGa Blox) — 15 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
37
37
 
38
38
  ## standalone framework
@@ -182,7 +182,9 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
182
182
  is an empty subclass of `_Model_Compass_ApprovalDecision`; the entire approval/notification/
183
183
  manager-reassignment/VIP-auto-approve flow lives in the parent and branches at runtime on
184
184
  `clientIdentifier === 'Compass_Canada'` (email-template UUID + EN/FR localization). Fixes to the
185
- parent cover Canada automatically — no per-tenant change. See
185
+ parent cover Canada automatically — no per-tenant change. **Canada has no manager (VIP)
186
+ auto-approve by business rule**: no setting-18 limit is seeded, so it never fires; empty
187
+ `ApprovalDecisionTypes` is expected. See
186
188
  [Approval-Decision Flow](../compass-usa/features/approval-decision-flow.md).
187
189
  - **Canada shares the duplicate-`Users`-row / stranded-approval defect — confirmed 2026-08-20.**
188
190
  `Client_CompassCanada` held **2** approvals assigned to dead duplicate user rows (vs. 53 in
@@ -2,7 +2,7 @@
2
2
 
3
3
  | Doc | Framework | Summary |
4
4
  |-----|-----------|---------|
5
- | [Compass Approval-Decision Flow (Notifications & Manager Reassignment)](features/approval-decision-flow.md) | 2.0 | Compass sales-order approval-decision / notification flow — approval+CC email lists, manager reassignment, VIP auto-approve, EN/FR localization; open when touch |
5
+ | [Compass Approval-Decision Flow (Notifications & Manager Reassignment)](features/approval-decision-flow.md) | 2.0 | Compass sales-order approval-decision / notification flow — approval+CC email lists, manager reassignment, settings-driven manager (VIP) auto-approve, EN/FR loc |
6
6
  | [Compass ASN → ItemFulfillment Auto-Creation](features/asn-to-item-fulfillment.md) | 2.0 | Compass USA ASN posting auto-creates the ItemFulfillment on the upstream SalesOrder and propagates tracking at every granularity; open when working ASN→IF creat |
7
7
  | [Compass Contact Email Resolution & Crossed `primaryContactEmailAddressId` Pointers](features/contact-email-resolution.md) | 2.0 | Any Compass code reading a contact's email must assert the email row belongs to that contact (~14% of pointers are crossed); open before reading Compass contact |
8
8
  | [Corporate vs Field laptop personas — the sector-52 split (designed, NOT built)](features/corporate-vs-field-laptop-personas.md) | 2.0 | Agreed design for splitting the Compass catalogue into a Corporate laptop and a field "laptop kit": attach Corporate at sector `J00000` via `Locations_Personas` |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-10-06
9
+ updated: 2026-10-09
10
10
  owners: ["apeterson", "dfranks", "bala"]
11
11
  files:
12
12
  - _underscore/Model/Compass/ApprovalDecision.php
@@ -16,11 +16,14 @@ files:
16
16
  - _underscore/Model/Client/ApprovalTemplateStage.php
17
17
  - _underscore/Model/Quad/SalesOrder.php
18
18
  - dbchanges2/Client_Compass/2026-09-10b - RegisterApprovalDecisionPrePostInterceptor.sql
19
+ - dbchanges2/Core/2026-10-09a - ManagerAutoApproveSettings.sql
20
+ - dbchanges2/Client_Compass/2026-10-09a - ManagerAutoApproveSettingsSeed.sql
19
21
  related:
20
22
  - mr-ma-order-approval-and-status.md
21
23
  - ../../../2.0/apps/toga25-supply/features/action-button-rule-engine.md
22
24
  - ../../../2.0/apps/_underscore/features/sales-order-denial-reason.md
23
25
  - people-file-user-lifecycle.md
26
+ - persona-model-and-levy-gating.md
24
27
  - stranded-approval-reassignment.md
25
28
  - ../../compass-canada/profile.md
26
29
  - ../../compass-canada/features/french-order-email-localization.md
@@ -29,7 +32,7 @@ related:
29
32
  - ../../../2.0/apps/_underscore/features/core-domains-app-host-registry.md
30
33
  - ../../../1.0/apps/worker/features/compass-manager-approval-reminder-emails.md
31
34
  ---
32
- Compass sales-order approval-decision / notification flow — approval+CC email lists, manager reassignment, VIP auto-approve, EN/FR localization; open when touching Compass approval emails, manager swaps, or stuck/stranded approvals.
35
+ Compass sales-order approval-decision / notification flow — approval+CC email lists, manager reassignment, settings-driven manager (VIP) auto-approve, EN/FR localization; open when touching Compass approval emails, manager swaps, or stuck/stranded approvals.
33
36
 
34
37
  ## Summary
35
38
  Whole flow lives in shared parent `_Model_Compass_ApprovalDecision`. Both tenant subclasses `_Model_Compass_Usa_ApprovalDecision` and `_Model_Compass_Canada_ApprovalDecision` are **empty** (`extends _Model_Compass_ApprovalDecision {}`, no overrides). Client/language behavior is NOT subclassed — it branches at runtime in the parent on `$api->client->clientIdentifier` (`Compass_Usa` vs `Compass_Canada`). Fix a parent method → applies to both Compass US and Canada; no per-client change.
@@ -84,13 +87,29 @@ Only **three** clients ever build a supply order link — Compass USA, Compass C
84
87
  - **`worker2` — zero** supply/commerce link generation.
85
88
  - **Out of scope, same bug class, deliberately left alone:** user-facing commerce `userOrderUrl` links (`Model/Compass/SalesOrder.php` ~L484 and ~L1060, `Model/Compass/ApprovalDecision.php` ~L423 and ~L730) plus ~ten inline commerce URL literals across worker crons.
86
89
 
87
- ### VIP manager auto-approve — rule + its three (all supervisor-derived) triggers
88
- A manager-stage (step 2) approval auto-approves iff the assigned manager's `Users.c_isVip = 1` AND order subtotal `<= _Model_Compass_SalesOrder::AUTO_APPROVE_IF_MANAGER_IS_VIP_AND_UNDER_AMOUNT` (= `5000`). Subtotal = `SUM(SalesOrderItems.quantity * price)` via `_Model_Client_SalesOrder::_subtotal()`.
90
+ ### Manager auto-approve (VIP) — settings-driven rule + its three triggers (TRUE-82608, 2026-10-09)
91
+ One check, `_Model_Compass_ApprovalDecision::shouldAutoApproveManagerStep(int $managerUserId, float $orderSubtotal)`. Step 2 auto-approves iff ALL:
92
+ 1. Manager has a limit = `MAX(PersonaGlobalSettings.value)` with `settingId = 18` (`manager-auto-approve-limit`) over the manager's `Users_Personas` (numeric values only, `REGEXP` filter) — `getManagerAutoApproveLimit()`.
93
+ 2. Order subtotal `<=` that limit. Subtotal = `SUM(SalesOrderItems.quantity * price)` (`_Model_Client_SalesOrder::_subtotal()`), never `_total`.
94
+ 3. Manager has NOT opted out: no `UserGlobalSettings` row `settingId = 19` (`manager-auto-approve-opt-out`) with value `'1'` — `hasManagerOptedOutOfAutoApprove()`.
89
95
 
90
- Evaluated in exactly three places, all deriving the manager from a supervisor relationship:
91
- - **(a) Order creation** — `_Model_Compass_SalesOrder::postPost`, inside `elseif (!empty($managerUserUuid))`, where `$managerUserUuid` comes from requester's `Users.supervisorUserId`.
92
- - **(b) "Order for" / contact change** — `_Model_Compass_SalesOrder::postPut`, only when the PUT payload changes `contact`; re-derives the manager from the new contact's supervisor.
93
- - **(c) Manager reassignment** — `_Model_Compass_ApprovalDecision::handleManagerReassignment`, wired into `postPut` **only** (added by TRUE-78851). Writes log notes `"Manager reassigned — approval decision reset"` (non-VIP) or `"Manager approval skipped due to VIP logic"` (VIP auto-approve).
96
+ Constants `SETTING_ID__MANAGER_AUTO_APPROVE_LIMIT = 18` / `SETTING_ID__MANAGER_AUTO_APPROVE_OPT_OUT = 19` on `_Model_Compass_ApprovalDecision`. Note text from `buildManagerAutoApproveNote()` is unchanged (`"Auto-approved: Manager is VIP and order total <= 5000"`, amount now read from the setting).
97
+
98
+ **Replaced** the old rule `Users.c_isVip = 1 AND subtotal <= _Model_Compass_SalesOrder::AUTO_APPROVE_IF_MANAGER_IS_VIP_AND_UNDER_AMOUNT (5000)` — that constant is **removed**; do not reintroduce `c_isVip` here. Why: a VIP manager (`Client_Compass` user **60817**) wanted to approve every order himself, but `c_isVip` cannot be cleared (it comes from the HR people file and other teams use it).
99
+
100
+ Data (all ids identical in prod, dev-sandbox, client-sandbox):
101
+ - `dbchanges2/Core/2026-10-09a - ManagerAutoApproveSettings.sql` — `Core.Settings` ids 18/19, pinned + guarded.
102
+ - `dbchanges2/Client_Compass/2026-10-09a - ManagerAutoApproveSettingsSeed.sql` — `PersonaGlobalSettings` persona **35** (VIP Role) / setting 18 = `'5000'`; `UserGlobalSettings` user 60817 / setting 19 = `'1'`. Literal ids, no Core reference.
103
+ - **Release order: Core SQL → Client_Compass SQL → deploy `_underscore`.** Code first = ALL VIP auto-approval stops until seeded.
104
+ - Persona 35 is a safe stand-in for `c_isVip`: PeopleFile grants it to every VIP and removes it when VIP is dropped (prod: 2269/2269 active VIPs hold it, no non-VIP does) — see [Persona model](persona-model-and-levy-gating.md). Replay of the last 5,184 prod manager decisions: old rule 1,663 auto-approved, new 1,662; the only difference is user 60817.
105
+ - To change a limit: edit the persona's setting-18 row (or seed one on another persona). To exempt one manager: add their setting-19 = `'1'` row.
106
+
107
+ Evaluated in exactly three places, all calling the shared check:
108
+ - **(a) Order creation** — `_Model_Compass_SalesOrder::postPost`, inside `elseif (!empty($managerUserUuid))` (manager = delegate or requester's `Users.supervisorUserId`).
109
+ - **(b) "Order for" / contact or delegate change** — `_Model_Compass_SalesOrder::postPut`; re-derives the manager. Compares **subtotal** since TRUE-82608 (before, it wrongly used `_total` = subtotal + shipping + tax).
110
+ - **(c) Manager reassignment** — `_Model_Compass_ApprovalDecision::_evaluateManagerVip` via `handleManagerReassignment` (wired into `postPut` only, TRUE-78851); also reached by the bulk stranded-approval worker. Log notes `"Manager reassigned — approval decision reset"` (no auto-approve) or `"Manager approval skipped due to VIP logic"`.
111
+
112
+ **Compass Canada has no manager auto-approve, by business rule.** Same code path runs, finds no setting-18 row (nothing seeded; persona 35 does not exist there), never auto-approves. Empty Canada `ApprovalDecisionTypes` is expected, not a bug.
94
113
 
95
114
  ### Who becomes the step-2 manager: delegate note first, supervisor otherwise
96
115
  `_Model_Compass_SalesOrder` resolves the step-2 (Manager) manager two ways:
@@ -155,9 +174,9 @@ When a step-2 (Manager) decision is reassigned, `_swapManagerEmailAddress()` upd
155
174
  - **⚠ A pure reassignment notifies nobody.** Changing only `ApprovalDecisions.assignedToUserId` sends no email: `handleApprovalDecision()` returns early when `isApproved` is absent from the payload, and `handleAssignmentNotification()` fires only for step 1. The manager approval-request email is triggered when the admin approves step 1, not when an assignment changes. Any tool/backfill/admin action that repoints a manager must send its own notification.
156
175
  - **`_evaluateManagerVip()` silently no-ops when the order has no requester `Users` row.** Runs `INNER JOIN Users ON Users.contactId = SalesOrders.contactId`; with nothing linked to the order's contact it returns zero rows and returns without evaluating VIP. Pre-existing — a second distinct way (alongside the no-supervisor POST-path gap below) for an order to never get VIP auto-approve considered.
157
176
  - **Stale supervisor links are the bigger source of stuck approvals — and the supervisor path does no `isActive` check.** Prod 2026-08-12: **869 active users have a `supervisorUserId` pointing at an inactive `Users` row**, and only **2** of those supervisors have a live twin account. The delegate/duplicate problem at the same moment: **77 open step-2 decisions assigned to inactive users** — only **11** automatically resolvable (8 by matching `c_hrEmpPersonnelNbr`, 3 more by email), the other **66** managers who left with no live record. Read them differently: *same human, new account* is mechanically fixable; *departed manager* needs a human to name the replacement. (Counts are point-in-time.)
158
- - **VIP auto-approve bypassed when the requester has no supervisor (POST-path gap).** All three VIP triggers are supervisor-derived. When a requester has `Users.supervisorUserId = NULL`, order creation skips the manager branch (no step-2 decision, no manager). If an admin then manually adds a manager, that CREATES the step-2 decision as a POST to `/approval-decisions` → `_Model_Compass_ApprovalDecision::postPost`, which calls only `logApprovalDecision` + `handleApprovalDecision` — neither contains VIP auto-approve logic. So a VIP manager under $5000 added to a no-supervisor order never auto-approves and must be manually overridden. TRUE-78851 wired `handleManagerReassignment` into `postPut` only, never the first assignment (POST).
177
+ - **VIP auto-approve bypassed when the requester has no supervisor (POST-path gap).** All three VIP triggers are supervisor-derived. When a requester has `Users.supervisorUserId = NULL`, order creation skips the manager branch (no step-2 decision, no manager). If an admin then manually adds a manager, that CREATES the step-2 decision as a POST to `/approval-decisions` → `_Model_Compass_ApprovalDecision::postPost`, which calls only `logApprovalDecision` + `handleApprovalDecision` — neither contains VIP auto-approve logic. So a manager with a setting-18 limit, added to a no-supervisor order under that limit, never auto-approves and must be manually overridden. TRUE-78851 wired `handleManagerReassignment` into `postPut` only, never the first assignment (POST).
159
178
  - **Diagnostic signature:** absence of both `handleManagerReassignment` log notes in `Logs_<Tenant>.Record`, plus a step-2 decision whose `approvalDecisionTypeId = NULL` and `decidedByUserId` = the admin (not the manager). Order event log = logs cluster schema `Logs_Compass` (per-client `Logs_<Tenant>`), table `Record`, columns `(recordId, primaryKeyId, userId, timestamp, note)`; SalesOrder `RECORD_ID = 14`, `ApprovalDecisions` = **179**. Full manager-assignment history reconstructable from these tables — including telling an in-app change from a direct DB edit: see [Record Change Audit Log](../../../2.0/apps/_underscore/features/record-change-audit-log.md).
160
- - Real incident: order SA134461 — VIP manager Spyros Gravas, subtotal $926.47 (both auto-approve conditions met) still required manual override because the requester had no supervisor.
179
+ - Real incident: order SA134461 — VIP manager Spyros Gravas, subtotal $926.47 (auto-approve conditions met) still required manual override because the requester had no supervisor.
161
180
  - **⚠ The `!$hasManager` branch strands orders in an unrecoverable decided-but-untyped state — worst of the no-supervisor family.** When the "Order for" contact is changed to a user with no `Users.supervisorUserId`, `_Model_Compass_SalesOrder::postPut`'s `!$hasManager` branch (`Model/Compass/SalesOrder.php` ~L1018) PUTs `assignedToUser`, `decidedByUser`, `isApproved`, `approvalDecisionType`, `note` **and** `dtDecision` all to `null` — deliberately wiping an existing VIP auto-approval. A later manual approval sets `isApproved = 1` + `decidedByUserId` but never stamps `approvalDecisionTypeId` (correct — not a *system* decision). End state: `isApproved = 1`, `approvalDecisionTypeId = NULL`, and frequently a stale "Auto-approved: Manager is VIP and order total <= 5000" note.
162
181
  - **Frontend consequence (makes it unrecoverable):** blox derives `isPreApproved` from `!!stage.ApprovalDecisionTypes.name`, so it is `false`. The approval modal locks the stage's inputs and hides the Stage Actions block containing the only "Reset to Pending" control — no admin action can recover. This is why the 2026-08-31 frontend fix gates on `stepOneUndecided` rather than `isPreApproved` (see [Action-Button Rule Engine](../../../2.0/apps/toga25-supply/features/action-button-rule-engine.md)).
163
182
  - **Diagnostic signature in order activity log**, in order: `"Manager approval skipped due to VIP logic"` (creation-time VIP auto-approve), then `"Manager assignment cleared: new user has no supervisor"` AND `"VIP auto-approval removed: new user has no supervisor"` (written only by that `!$hasManager` branch), then `"Manager Approved by <admin>"` (manual approval).
@@ -177,9 +196,11 @@ When a step-2 (Manager) decision is reassigned, `_swapManagerEmailAddress()` upd
177
196
  - **⚠ An exemption from a server-side guard must be decided from the DATABASE, never from a client-supplied field.** The step-1 guard originally skipped itself when the payload carried `approvalDecisionType` / `approvalDecisionTypeId`, treated as a "system auto-approval" marker. That value comes from the request body → any caller could set it and skip the check. The early return and its `_isSystemAutoApproval` helper were removed in PR review. Checked against prod first: all **5,028** step-1 auto-approvals in Compass history are MR/MA orders (Canada has none), so the DB-backed order-number check covers every real case, and VIP auto-approvals land on step 2 and exit at the step check. General rule for any guard added here.
178
197
  - **⚠ The green approve tick used to silently replace the step-2 manager with the approver.** `makeApprovalDecision()` / `updateApprovalDecision()` in supply hardcoded `assignedToUserId: { uuid: userUuid }` into every single-stage approve payload, so an admin approving the manager stage overwrote the real manager with themselves — and the timeline read "Manager Approved", making it look like the manager acted. Confirmed on **SA136901** (07:18:35 re-sent an assignee that had not changed; Dawn Anderson already assigned by Emily at 06:50:06); also hit **SA136807** (Angel Almodovar replaced) and **SA137080** (delegate manager Daniel Mezzanares replaced). Fixed front-end by removing the field — `decidedByUserId` already records who approved. The "Approve All Stages" path was always correct, which is why it never reproduced there. Detail: [Record Modals & Nested Tables](../../../2.0/apps/toga25-supply/features/record-modals-and-nested-tables.md).
179
198
  - **⚠ A manager swap sent in the SAME save as an approval is never logged, and the email list keeps the old manager.** `_evaluateManagerVip()` returns immediately when the request carries `isApproved`, assuming a request is *either* an assignment *or* an approval. When both arrive together the `"Manager reassigned"` note is skipped (the feature works — fired 757 times since May) AND so is the `SalesOrderEmailAddresses` swap, so the outgoing manager stays on the notification list. A handler for the combined case was drafted on the `_sandbox-client` branch only and is NOT on `TRUE-81900` — known gap, not shipped.
199
+ - **⚠ Never read the auto-approve LIMIT from `UserGlobalSettings`.** Users can write their own `UserGlobalSettings` rows (the "own" ACL), so a per-user limit would let anyone raise their own approval ceiling. Limit lives only on `PersonaGlobalSettings` (admin-controlled). The opt-out is per-user on purpose: any write to it can only force manual approval, never skip it. Settings 18/19 are not in the user-writable allowlist of the `UserGlobalSettings` own-row guard — see [own-row guards](../../../2.0/apps/api2/features/api-payload-interceptors.md#own-row-guards-in-a-pre-interceptor).
180
200
  - **The upstream cause of an unassigned step 2 is a user saved with no `supervisorUserId`.** The admin add-user screen allows saving a user without one, and the PEOPLE-file import may or may not backfill it later. That data hole feeds both the `!$hasManager` wipe above and the never-created step-2 decision at order creation. Not fixed — the guard only stops the order being *stranded*, it does not give it a manager.
181
201
 
182
202
  ## Change history
203
+ - 2026-10-09 — Manager auto-approve moved from `c_isVip` + hardcoded 5000 to persona setting 18 (limit) + user setting 19 (opt-out); postPut now compares subtotal (TRUE-82608). (bala)
183
204
  - 2026-10-06 — Recorded that no delegate + no supervisor saves an order with no step-2 decision, and that the commerce cart now blocks it (TRUE-82423). (bala)
184
205
 
185
206
  ## Related
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-10-05
9
+ updated: 2026-10-09
10
10
  owners: [bala, ajean, mhammontree]
11
11
  files:
12
12
  - worker2/Worker/Client/Compass/PeopleFile.php
@@ -51,7 +51,7 @@ below.
51
51
  | Base (was "All") | **1** | The **9** kits Levy and non-Levy both hold (81, 82, 84, 85, 105, 115, 120, 165, 166), plus the 71 items / 11 categories Levy also holds. **Every US user holds it.** |
52
52
  | Levy Purchaser | **24** | Levy's own **5** kits: 189, 193, 213, 231, 242. |
53
53
  | Non-Levy | **resolved by name** — **id 40, number 42 in prod** | The other 14 persona-1 kits / 123 items / 3 categories. Granted to non-Levy users only. |
54
- | VIP | 35 | VIP add-on. |
54
+ | VIP | 35 | VIP add-on. Carries the manager auto-approve limit (`PersonaGlobalSettings` setting 18 = `'5000'`) — see [Approval-Decision Flow](approval-decision-flow.md). |
55
55
  | CDL (MacBook / Surface) | 30 | `@compassdigital.io` + VIP users. |
56
56
 
57
57
  **Never hardcode the Non-Levy id, and never assume it matches the number.** In prod it is **id 40**
@@ -184,6 +184,7 @@ Both are **latent, not live, and not yet fixed** — each needs its own ticket.
184
184
  constants from this file into tickets, docs, or chat transcripts.
185
185
 
186
186
  ## Change history
187
+ - 2026-10-09 — Persona 35 now carries the manager auto-approve limit (setting 18); PeopleFile keeps it in exact sync with `c_isVip` (prod 2269/2269), so do not decouple the two (TRUE-82608). (bala)
187
188
  - 2026-10-05 — Linked the deferred
188
189
  [corporate vs field laptop split](./corporate-vs-field-laptop-personas.md) (Corporate attached at
189
190
  sector `J00000` via `Locations_Personas`; Field assigned here in `PeopleFile`, mirroring the Levy
@@ -5,7 +5,7 @@ project: _Underscore
5
5
  client: compass-usa
6
6
  type: workflow
7
7
  status: active
8
- updated: 2026-09-16
8
+ updated: 2026-10-09
9
9
  owners: ["jcardinal", "bala", "dfranks"]
10
10
  files: []
11
11
  related:
@@ -32,7 +32,8 @@ Compass customer SO (SalesOrders.customerId = 2, number SA/MA/MR)
32
32
  --PurchaseOrders_SalesOrders--> Agilant/NetSuite SO (customerId = 3)
33
33
  ```
34
34
  - **SA** orders are user-created in Compass then sent to MITS; **MR** orders are created retroactively from the inbound MITS PO (they do not originate as Compass-entered SOs); MA exists too. Structurally MR uses the same bridges as SA.
35
- - One Compass SO item can split into multiple PO items (bundles / `c_isConfiguration`, `parentSalesOrderItemId`, `bundleId`).
35
+ - One Compass SO item can split into multiple PO items (bundles / `c_isConfiguration`, `parentSalesOrderItemId`). Kit lines link via `SalesOrderItems.bundleItemId → BundleItems.bundleId`; `SalesOrderItems.bundleId` is effectively unused — never query kits by it.
36
+ - `Client_Compass` order numbers `29xxxx` and `45`–`48xxxxxxxxx` are the Office Depot / Agilant side copies (`customerId` 1 / 3), not commerce orders — exclude them when counting Compass orders.
36
37
 
37
38
  ## Steps
38
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.997",
3
+ "version": "1.0.999",
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",