toga-ai 1.0.666 → 1.0.667

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,11 +6,12 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-21
9
+ updated: 2026-08-27
10
10
  owners: [tcox, bala, apeterson]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  related:
14
+ - ../../toga2-commerce/features/bundle-item-visibility-and-selectability.md
14
15
  - ../architecture.md
15
16
  - v2-api-error-codes.md
16
17
  - tableview-apiwhereclause-row-filtering.md
@@ -109,8 +110,11 @@ switching that tenant to the join endpoint.
109
110
 
110
111
  ### ⚠ A `where` clause on a single-uuid READ is silently IGNORED
111
112
 
112
- **`GET /v2/items/{uuid}` does not filter.** The by-uuid READ path loads the record through
113
- `locateRecord()`, which resolves by uuid and **never reads `httpOptions['where']`**. The parameter
113
+ **`GET /v2/items/{uuid}` does not filter.** A route with a uuid (`count($routePair) == 2`) is
114
+ classified **`ACTION__READ`**, not `ACTION__LIST` (`V2.php:3022-3026`). The `ACTION__READ` branch
115
+ runs from `V2.php:4399` to the `ACTION__DELETE` case (~`V2.php:5478`) and contains **no `where`
116
+ handling whatsoever** — `where` is parsed, then applied only in the `ACTION__LIST` branch
117
+ (`V2.php:3526`) and in the uuid-less DELETE/PUT lookup (~`V2.php:5529`). The parameter
114
118
  is accepted, produces no error, and has no effect — so a caller that adds
115
119
  `where=(Items.isActive:eq:1)` to a single-uuid GET gets the record back **regardless of the
116
120
  filter** and believes it has been gated.
@@ -127,6 +131,17 @@ rest of the code expects — a `null` there is then a truthful *"missing or filt
127
131
  example: [TOGa Commerce inactive-item purchase
128
132
  gating](../../toga2-commerce/features/inactive-item-purchase-gating.md).
129
133
 
134
+ ### ⚠ `where` NEVER prunes a nested child array — not even on LIST
135
+
136
+ `buildWhereExpressionsFromOptions` is invoked with `defaultTable: $modelName::TABLE`, the **root**
137
+ model. A `where` therefore constrains **which root rows return**; the nested one-to-many arrays are
138
+ built afterwards by `getFullModelData()`, which the `where` never touches. **"Filter the nested
139
+ array with `where`" is not a capability the V2 engine has** — at any depth, on any route. To reduce
140
+ a child collection you must either query the child route directly (with the parent FK in the
141
+ `where`) or filter client-side. Worked example:
142
+ [bundle item visibility & selectability](../../toga2-commerce/features/bundle-item-visibility-and-selectability.md),
143
+ where a bundle-item `isActive` filter was inert for this reason *and* the uuid-READ reason above.
144
+
130
145
  This is the mirror image of the LIST → READ flip below: there, an unrecognized param moves you onto
131
146
  the READ path and you get a loud 404; here, a **recognized** param is simply inert on that path and
132
147
  you get a quiet wrong answer.
@@ -164,7 +179,11 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
164
179
  `Contacts.uuid` with no read grant on `Contacts`, as long as you never **select** a
165
180
  `Contacts.*` field. Useful when you have a child grant but not the parent's fields.
166
181
  - **Referencing a table in `where` that you did not `join`** produces invalid SQL, which is
167
- uncaught → **`EO-1` 500**.
182
+ uncaught → **`EO-1` 500**. The field string is passed through **verbatim**: `V2.php:6959-6962`
183
+ prepends the default table only when the field contains no `.`, and no auto-join is ever generated
184
+ for an arbitrary table prefix (joins come only from explicit `join`/`ojoin` and `SPECIAL_FIELDS`).
185
+ So a **typo'd table prefix is neither rejected nor reported** — it reaches SQL as an unknown
186
+ column.
168
187
  - **Nested route `/contacts/{uuid}/contact-attempts`** auto-filters by the child's FK
169
188
  (discovered by **model class**, not field name) and requires **record-level READ on the
170
189
  parent** but **no field grant** on it.
@@ -215,6 +234,17 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
215
234
  invisible; re-request with `fields=` to force `EZ-2` and see the denied list.
216
235
 
217
236
  ## Change history
237
+ - 2026-08-27 — Pinned the **mechanism** behind the silently-ignored uuid-READ `where` to exact code:
238
+ a uuid'd route is classified `ACTION__READ` (`V2.php:3022-3026`) and that branch
239
+ (`V2.php:4399` → ~`5478`) has **no `where` handling at all**; `where` is applied only in
240
+ `ACTION__LIST` (`V2.php:3526`) and the uuid-less DELETE/PUT lookup (~`V2.php:5529`). Added two
241
+ further limits found in the same read: **`where` never prunes a nested child array** (it is built
242
+ with `defaultTable = <root>::TABLE`, and nested collections come from `getFullModelData()`, which
243
+ the `where` never touches — so nested filtering is not a V2 capability on any route), and a
244
+ `where` field with an **unknown table prefix passes into SQL verbatim** (`V2.php:6959-6962` only
245
+ prepends the default table when there is no `.`; no auto-join is generated), so a typo'd prefix is
246
+ never reported. Found while investigating unfiltered bundle items on a Compass USA order.
247
+ (apeterson)
218
248
  - 2026-08-21 — Documented that **`join`/`ojoin` drive the WHERE/filter, not the response shape**:
219
249
  output nesting comes from FK relationships + requested `fields` (`getFullModelData()`), and an
220
250
  aliased `ojoin` column never becomes a readable output field. Worked example: Compass's
@@ -3,6 +3,7 @@
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
5
  | [TOGa Commerce (toga2-commerce / commerce2-react) Architecture](architecture.md) | `toga2-commerce` (npm package name **`commerce2-react`**, product name **TOGa Commerce**) is the customer-facing **B2B commerce storefront** of the 2.0 platform | src/main.tsx, src/App.tsx, src/routes.tsx, src/contexts/AuthContext.tsx, src/contexts/helpers/getLoginSettings.ts, src/api/axiosInstance.ts, src/stores/, src/themeConfig/ThemeContext.tsx, src/fieldsConfig/index.ts, src/hooks/useAssignClientFields.ts, vite.config.ts, package.json |
6
+ | [Bundle item visibility & selectability (the three flags, and why they are enforced nowhere but the client)](features/bundle-item-visibility-and-selectability.md) | Which components of a kit a shopper can see and choose is decided **entirely in the browser**. | toga2-commerce/src/pages/BundleView/api/BundleApi.tsx, toga2-commerce/src/pages/BundleView/helpers/formatOriginalBundleData.ts, toga2-commerce/src/pages/BundleView/helpers/formatBundleToAddToCart.ts, toga2-commerce/src/pages/BundleView/hooks/useBundleEditFromLocalStorage.ts, toga2-commerce/src/stores/useBundleBuilderZu.ts, _underscore/Model/Client/BundleItem.php, _underscore/Model/Client/SalesOrderItem.php, api2/Component/Api/V2/V2.php |
6
7
  | [Cart Bundle Submission & the bundleUuid Identity Contract](features/cart-bundle-submission-and-identity.md) | How cart **bundles** (kits) are turned into `SalesOrderItems` when a cart is submitted or an existing order is edited, and the **identity-field contract** every | src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts, src/utils/formatSalesOrderBundlesFromApi.ts, src/stores/useCartStoreZu.ts, src/pages/OrderDetails/helpers/formatSalesOrderDataFromLocalStorage.ts, src/pages/OrderDetails/view/components/OrderItems.tsx |
7
8
  | [Cart Notification Emails — duplicate prevention](features/cart-notification-emails.md) | On the cart "Notifications" section a user can add CC email addresses to an order. | src/pages/Cart/CartPage.tsx, src/pages/Cart/view/cartForm/CartForm.tsx, src/stores/useEmailOptionsStore.ts, src/stores/useCartSalesQuoteZu.ts, src/pages/Cart/viewModel/FIELDS/*/*/*/CARTPAGE.ts |
8
9
  | [Cart Order-Total & Shipping Computation](features/cart-order-total-computation.md) | The Cart summary section (subtotal / shipping / tax / total) is **data-driven** from `cartData`. | toga2-commerce/src/pages/Cart/viewModel/useCartViewModel.ts, toga2-commerce/src/pages/Cart/CartPage.tsx, toga2-commerce/src/pages/Cart/view/cartForm/CartForm.tsx, toga2-commerce/src/pages/Cart/helpers/shippingOptionGates.ts, toga2-commerce/src/pages/Cart/api/CartApi.ts |
@@ -0,0 +1,162 @@
1
+ ---
2
+ title: Bundle item visibility & selectability (the three flags, and why they are enforced nowhere but the client)
3
+ framework: "2.0"
4
+ repo: toga2-commerce
5
+ project: TOGa Commerce
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-27
10
+ owners: ["apeterson"]
11
+ files:
12
+ - toga2-commerce/src/pages/BundleView/api/BundleApi.tsx
13
+ - toga2-commerce/src/pages/BundleView/helpers/formatOriginalBundleData.ts
14
+ - toga2-commerce/src/pages/BundleView/helpers/formatBundleToAddToCart.ts
15
+ - toga2-commerce/src/pages/BundleView/hooks/useBundleEditFromLocalStorage.ts
16
+ - toga2-commerce/src/stores/useBundleBuilderZu.ts
17
+ - _underscore/Model/Client/BundleItem.php
18
+ - _underscore/Model/Client/SalesOrderItem.php
19
+ - api2/Component/Api/V2/V2.php
20
+ related:
21
+ - ./cart-bundle-submission-and-identity.md
22
+ - ./inactive-item-purchase-gating.md
23
+ - ../architecture.md
24
+ - ../../api2/features/v2-rest-query-contract.md
25
+ - ../../api2/features/api-payload-interceptors.md
26
+ - ../../../clients/compass-usa/profile.md
27
+ ---
28
+
29
+ ## Summary
30
+
31
+ Which components of a kit a shopper can see and choose is decided **entirely in the browser**.
32
+ Investigated 2026-08-27 while tracing how part number `S24D402GAN` reached Compass USA order
33
+ `SA136441` (bundle 189, "LEVY - HP ZBOOK 8"). Findings are **platform-wide, not tenant-specific** —
34
+ no `_Model_<Client>` override participates in any of these paths.
35
+
36
+ Three facts, in order of importance:
37
+
38
+ 1. **The server-side `isActive` filter on the bundle fetch has never executed.** It is inert for
39
+ three independent reasons (below), so bundle items arrive at the client **completely
40
+ unfiltered**.
41
+ 2. **`Client.BundleItems`' three flags mean different things depending on the row type**
42
+ (plain item vs. fee vs. child bundle), so no uniform flag filter can express "selectable."
43
+ 3. **Nothing on the backend validates that a posted `salesOrderItems[].bundleItem` is currently
44
+ selectable**, so any direct API caller can post any bundleItem uuid.
45
+
46
+ ## The flag conventions (non-obvious — the flag names lie)
47
+
48
+ `Client.BundleItems` carries `isActive`, `isRequired`, `isVisible`. Their meaning varies **by row
49
+ type**:
50
+
51
+ | Row type | isVisible | isRequired | isActive | What it actually means |
52
+ |---|---|---|---|---|
53
+ | Plain component | true | either | true | shown, optionally selectable |
54
+ | **FEE** | **false** | **true** | true | *not shown, but mandatory* — must be on the order or it cannot be placed |
55
+ | **Child bundle** (nested kit) | true | false | **false** | intentionally inactive rows; `isActive = false` does **not** mean disabled here |
56
+
57
+ So `isVisible = false` does not mean "not orderable", and `isActive = false` does not mean
58
+ "disabled". **Any selectability filter must branch on row type first.** This is what makes the
59
+ client-side carve-outs below necessary rather than accidental.
60
+
61
+ ## How it works
62
+
63
+ ### The dead `where` clause on the bundle fetch
64
+
65
+ `src/pages/BundleView/api/BundleApi.tsx` — both `fetchSingleBundleData` (~L81-83) and
66
+ `fetchBundleImages` (~L125-127) send, against `GET /bundles/{uuid}`:
67
+
68
+ ```
69
+ where: { and: [ { "BundlesItems.isActive": { "=": "true" } } ] }
70
+ ```
71
+
72
+ Three independent reasons it does nothing:
73
+
74
+ - **(a) It is a uuid'd GET.** The V2 `ACTION__READ` branch contains no `where` handling at all —
75
+ see [V2 REST query contract](../../api2/features/v2-rest-query-contract.md).
76
+ - **(b) Even on the LIST route it would filter the wrong thing.** `where` constrains which **root**
77
+ rows return; it never prunes a nested one-to-many array. Nested children are serialized by
78
+ `getFullModelData()`, which the `where` never touches.
79
+ - **(c) The table name is a typo.** The model declares `TABLE = 'BundleItems'`
80
+ (`_underscore/Model/Client/BundleItem.php:6`); the query says `BundlesItems` (extra `s`).
81
+
82
+ ### Client-side filtering, and its two deliberate carve-outs
83
+
84
+ `src/pages/BundleView/helpers/formatOriginalBundleData.ts`:
85
+
86
+ - **The correct filter is `visibleItems`** (L11-13): `isVisible === true && isActive`.
87
+ - **CARVE-OUT A — fees (L81).** Reads the **raw** `bundlesDataOriginal.bundleItems` and filters only
88
+ `assetType.name === "FEE" && isRequired && isActive` — **no `isVisible` check**. Everything
89
+ matching is then force-added to the order as `updatedFees`
90
+ (`formatBundleToAddToCart.ts:32-44`). The user never sees it and cannot deselect it. **Any item
91
+ mis-tagged with assetType `FEE` is therefore ordered silently.**
92
+ - **CARVE-OUT B — child bundles (L29).** Reads the **raw** array and filters **only** on
93
+ `!!bundleItem?.childBundle` — all three flags ignored.
94
+
95
+ These two are the only fresh-page-load paths by which a non-selectable bundle item can reach an
96
+ order. Both are unguarded.
97
+
98
+ ### No server-side enforcement anywhere
99
+
100
+ - `_underscore/Model/Client/BundleItem.php` declares `isActive` / `isRequired` / `isVisible` as
101
+ plain `FIELD_BOOLEAN` columns with **no validation logic**.
102
+ - `_underscore/Model/Client/SalesOrderItem.php` has **no pre/post API payload interceptor** and no
103
+ `_Exception_Validation` throw site — it is plain metadata-driven CRUD.
104
+
105
+ ## Recommended direction (decided 2026-08-27, not implemented, no ticket yet)
106
+
107
+ **Enforce selectability at the API boundary, not by hardening the client filter.** A `prePost`
108
+ API payload interceptor on the `sales-orders` record should re-derive each posted
109
+ `salesOrderItems[].bundleItem` against its bundle and reject anything not currently selectable,
110
+ throwing `_Exception_Validation` (→ clean 400, no `Logs.Issue`, no Sentry noise — per the
111
+ exception-routing rule in `2.0/standards/backend-php.md`). Rationale: the client-side filter cannot
112
+ be made trustworthy while flag semantics vary by row type, and a client-only fix leaves the direct
113
+ API path wide open.
114
+
115
+ Also decided: **delete** the dead `where` clause in `BundleApi.tsx` rather than "fix" it — the V2
116
+ engine cannot prune nested children via `where` at all, and the clause could not express the
117
+ fee / child-bundle exceptions even if it worked.
118
+
119
+ ## Gotchas / known issues
120
+
121
+ - **⚠ Never read a flag on a `BundleItems` row without first knowing its row type.** Fee rows are
122
+ invisible-but-mandatory; child-bundle rows are inactive-by-convention.
123
+ - **⚠ Do not add a `where` to a uuid'd GET expecting nested filtering.** It is doubly inert (READ
124
+ branch ignores it; `where` filters root rows only).
125
+ - **⚠ A `where` field with an unknown table prefix reaches SQL verbatim.** `V2.php:6959-6962` only
126
+ prepends the default table when the field has no `.`, and no auto-join is generated for an
127
+ arbitrary prefix. A typo'd prefix becomes an unknown-column SQL error, not a validation message —
128
+ and on the READ path it is never even evaluated, which is exactly how the `BundlesItems` typo
129
+ survived unnoticed.
130
+ - **Latent, reported unused:** `src/pages/BundleView/hooks/useBundleEditFromLocalStorage.ts`
131
+ (L21-35) restores `bundleProgressContents` and `optionalAddons` verbatim from localStorage with
132
+ **zero flag re-validation**. It is mechanically reachable (`isEdited: true` set by
133
+ `addOptionalChildBundleToParent` / `removeOptionalChildBundleToParent` in
134
+ `useBundleBuilderZu.ts`), but the flow is **reported unused by anyone as of 2026-08-27** and is
135
+ ruled out as the cause of `SA136441`.
136
+
137
+ ## Open question (unresolved)
138
+
139
+ Which carve-out actually admitted part number `S24D402GAN` into order `SA136441` is **still
140
+ unknown** — the API response inspected was truncated before that line, so its `BundleItems` row was
141
+ never seen. Resolving it needs, for `S24D402GAN` in bundle 189: `isActive` / `isVisible` /
142
+ `isRequired`, `childBundleId`, and `item.assetType.name`. If it is neither a FEE-assetType row nor a
143
+ childBundle row, no fresh-load path explains it, and the next place to look is the cart sync or a
144
+ direct API write (`Logs.Api` request payload for that order).
145
+
146
+ ## Change history
147
+
148
+ - 2026-08-27 — Created from an investigation-only session (no code changed) into how part number
149
+ `S24D402GAN` reached Compass USA order `SA136441`. Recorded that the bundle fetch's
150
+ `BundlesItems.isActive` `where` clause in `BundleApi.tsx` has **never executed** (uuid'd GET
151
+ ignores `where`; `where` cannot prune nested children; and the table name is a typo for
152
+ `BundleItems`), so 100% of visibility/selectability enforcement is client-side; the two deliberate
153
+ raw-array carve-outs in `formatOriginalBundleData.ts` (fees at L81 with no `isVisible` check and
154
+ force-added as `updatedFees`; child bundles at L29 ignoring all three flags); the real
155
+ `Client.BundleItems` flag conventions (fees invisible-but-mandatory, child bundles
156
+ intentionally inactive) and the consequence that flag meaning varies by row type; and that
157
+ neither `_Model_Client_BundleItem` nor `_Model_Client_SalesOrderItem` validates selectability, so
158
+ a direct API caller can post any bundleItem uuid. Decided to enforce via a `sales-orders`
159
+ `prePost` interceptor throwing `_Exception_Validation` and to delete the dead `where` clause.
160
+ (apeterson)
161
+ </content>
162
+ </invoke>
@@ -29,7 +29,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
29
29
  - **talos** (TOGa IQ) — 7 doc(s) → [2.0/apps/talos/INDEX.md](2.0/apps/talos/INDEX.md)
30
30
  - **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
31
31
  - **ai-bdr** (AI-BDR) — 13 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
32
- - **toga2-commerce** (TOGa Commerce) — 19 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
32
+ - **toga2-commerce** (TOGa Commerce) — 20 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
33
33
  - **toga25-supply** (TOGa 2.5 Supply) — 13 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
34
34
  - **toga-blox** (TOGa Blox) — 13 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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.666",
3
+ "version": "1.0.667",
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",