toga-ai 1.0.833 → 1.0.835

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,7 +6,7 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-15
9
+ updated: 2026-09-17
10
10
  owners: [rgirish]
11
11
  files:
12
12
  - library/app/api/toga2.php
@@ -23,6 +23,7 @@ related:
23
23
  - ../../../2.0/apps/_underscore/features/acl-permission-chain.md
24
24
  - ../../../2.0/apps/dbchanges2/features/rerunnable-additive-inserts.md
25
25
  - ../../../clients/elite/features/netsuite-togasupply-sync.md
26
+ - ../../../clients/elite/features/supply2-tableview-config-drift.md
26
27
  ---
27
28
 
28
29
  ## Summary
@@ -138,7 +139,20 @@ without `AclRecordPermissions` the API role cannot `POST /item-classes` at all.
138
139
  sync re-walks transactions that reference them. That needs a
139
140
  [cursor rewind](../../worker/features/netsuite-togasupply-per-client-sync.md).
140
141
 
142
+ - **The tree depth is not capped — consumers that walk it with fixed joins can break silently.**
143
+ `Client_Elite` is exactly **5 levels** deep today (4 roots: 1 Lifecycle Services, 4 Technology
144
+ Sales, 22 Advisory and Modernization, 40 True Partner Services; verified 2026-09-15 that every
145
+ 5-level chain ends at a true root). Nothing stops NetSuite adding a 6th level. Anything that
146
+ resolves an item to its root class with a fixed number of `LEFT JOIN`s — such as Elite's
147
+ [Inventory service-item filter](../../../clients/elite/features/supply2-tableview-config-drift.md) —
148
+ will then silently mis-classify the deeper items with no error. Re-check depth when the NetSuite
149
+ tree changes.
150
+
141
151
  ## Change history
152
+ - 2026-09-17 — Added the **unbounded tree depth** caveat: `Client_Elite` is exactly 5 levels with 4
153
+ roots (1 / 4 / 22 / 40) and every chain verified to end at a true root, but nothing caps depth, so
154
+ any consumer walking to the root with a fixed join count breaks silently if NetSuite adds a level.
155
+ No code change. (rgirish)
142
156
  - 2026-09-15 — Documented the feature from an audit of the deployed code (library `37b55595`, worker
143
157
  `6637ae75`). **Found and fixed a field-name mismatch that made the feature a complete no-op since
144
158
  deploy:** the PHP sent `c_netsuiteInternalItemClassId` in 5 places while the live column and
@@ -61,5 +61,5 @@
61
61
  | [USPS DPV Deliverability Verdict (is this address actually insurable/shippable?)](features/usps-dpv-deliverability.md) | **USPS returning HTTP 200 with a populated address is NOT evidence that the address is deliverable.** The authoritative signal is USPS's **DPV (Delivery Point V |
62
62
  | [Refreshing a Local Dev Database from Beta (dev-sandbox)](workflows/local-db-refresh-from-beta.md) | How to reset a local 2.0 dev database from the **beta / dev-sandbox** environment: dump each schema (`Core`, `Client_<Id>`, `Logs_<Id>`, …) from the beta host, |
63
63
  | [Deleting a shared branch does not remove bad commits — a stale local clone merges them back](workflows/recreated-shared-branch-stale-local-remerge.md) | **Deleting and recreating a shared environment branch removes only the *ref*.** Every teammate who still has that branch checked out locally keeps the full pre- |
64
- | [Rotating Public TLS Certificates (ACM, CloudFront, ALB, Elastic Beanstalk)](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 |
64
+ | [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 |
65
65
  | [Running a 2.0 App Locally (browser, end-to-end via api2)](workflows/running-a-2.0-app-locally.md) | The full dependency chain required to run a 2.0 client app **through the browser**, end-to-end, against a **local `api2`** (e.g. |
@@ -1,12 +1,12 @@
1
1
  ---
2
- title: Rotating Public TLS Certificates (ACM, CloudFront, ALB, Elastic Beanstalk)
2
+ title: Rotating Public TLS Certificates (ACM, CloudFront, ALB, Elastic Beanstalk, API Gateway)
3
3
  framework: "2.0"
4
4
  repo: _underscore
5
5
  project: _Underscore
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-09-17
10
10
  owners: ["rgirish"]
11
11
  files: []
12
12
  related:
@@ -27,13 +27,15 @@ This runbook was written from the 2026-09-16 rotation in AWS account **654654170
27
27
  one IMPORTED multi-domain cert (14 SANs, copied into us-east-1, us-west-2 and eu-west-1) served
28
28
  4 CloudFront distributions and 12 ALB listeners and expired the same day.
29
29
 
30
- **The three things that bite you:**
30
+ **The four things that bite you:**
31
31
  1. **Elastic Beanstalk keeps its own saved cert setting**, separate from the ALB listener. Fixing
32
32
  the listener is not enough — the next deploy puts the old cert straight back.
33
33
  2. **CloudFront holds exactly ONE cert per distribution.** There is no staging. The swap *is* the
34
34
  cutover, and it takes 5-15 minutes.
35
35
  3. **An ACM wildcard matches exactly one label.** `*.togasupply.com` covers
36
36
  `elite.togasupply.com` but **not** `compass.beta.togasupply.com`.
37
+ 4. **API Gateway custom domains hold their own cert** and are invisible to a CloudFront/ALB/EB
38
+ sweep. This is what the 2026-09-16 rotation missed — see step 6b.
37
39
 
38
40
  **Root cause to avoid repeating:** an **IMPORTED** ACM cert never auto-renews. Always replace with
39
41
  **Amazon-issued, DNS-validated** ACM certs, which renew themselves.
@@ -123,6 +125,60 @@ one), and every environment stayed Green/Ok through the update — no traffic di
123
125
  **Gotcha:** an EB environment whose CloudFormation stack is in `DELETE_FAILED` cannot be updated at
124
126
  all — `update-environment` is refused. That needs separate stack cleanup.
125
127
 
128
+ ## Step 6b — Audit API Gateway custom domains (the 2026-09-16 miss)
129
+
130
+ **API Gateway is a FOURTH resource type.** The 2026-09-16 rotation swept CloudFront, ALB listeners
131
+ and EB saved configs, and reported "0 endpoints at risk". The next morning `webhook.togahub.com`
132
+ was hard-down — it is an API Gateway custom domain, so none of those three sweeps could ever have
133
+ found it. Proof of the failure, not just a stale cert:
134
+
135
+ ```
136
+ $ curl -sS -o /dev/null -w "http=%{http_code} ssl=%{ssl_verify_result}\n" https://webhook.togahub.com/
137
+ curl: (60) SSL certificate problem: certificate has expired
138
+ http=000 ssl=10
139
+ ```
140
+
141
+ **Find them from DNS — an API Gateway domain CNAMEs to `*.execute-api.<region>.amazonaws.com`:**
142
+
143
+ ```
144
+ aws route53 list-resource-record-sets --hosted-zone-id <zid> --max-items 400 \
145
+ --query "ResourceRecordSets[?Type=='CNAME'].[Name,ResourceRecords[0].Value]" --output text \
146
+ | grep -i execute-api
147
+ ```
148
+
149
+ Across all seven TOGa zones this found exactly one: `webhook.togahub.com` →
150
+ `d-yiy4od2ade.execute-api.us-east-1.amazonaws.com`.
151
+
152
+ **An unknown account ID in `InUseBy` can be AWS itself.** The expired cert's `InUseBy` listed 3
153
+ ALBs in account **250044486744**, which no one has a profile for. That is AWS-managed edge
154
+ infrastructure — an edge-optimized API Gateway domain terminates TLS on AWS's own load balancers.
155
+ Treat it as a pointer to API Gateway, **not** as a rogue account to go hunt credentials for.
156
+
157
+ **Permissions block this from the CLI.** The `GoAgilant-Developers` SSO role has **no**
158
+ `apigateway:GET` in any of the three accounts (654654170868, 502614707982, 975050298201) —
159
+ `get-domain-names`, `get-domain-name` and the `apigatewayv2` equivalents all return
160
+ `AccessDeniedException`. The domain cannot be listed, read or fixed with the standard developer
161
+ role. Use the Console, or get `apigateway:GET` + `apigateway:PATCH` added first.
162
+
163
+ **Check the endpoint type FIRST — it decides which field you patch.** `get-domain-name` returns
164
+ `endpointConfiguration.types`. An **EDGE** domain reads its cert from `us-east-1` and uses
165
+ `/certificateArn`; a **REGIONAL** domain reads from its own region and uses
166
+ `/regionalCertificateArn`. Patching the wrong field silently does nothing useful.
167
+ `webhook.togahub.com` turned out to be **REGIONAL** (us-east-1), despite `InUseBy` showing
168
+ AWS-managed edge ALBs — so do not infer the type from `InUseBy`.
169
+
170
+ ```
171
+ aws apigateway get-domain-name --domain-name webhook.togahub.com \
172
+ --query 'endpointConfiguration.types'
173
+
174
+ aws apigateway update-domain-name --domain-name webhook.togahub.com \
175
+ --patch-operations op=replace,path=/regionalCertificateArn,value=<new-arn>
176
+ ```
177
+
178
+ The domain goes `UPDATING` and takes a few minutes to return to `AVAILABLE` (~4 min observed).
179
+ Poll `domainNameStatus` before verifying — checking too early shows the old cert and looks like a
180
+ failed patch. No downtime beyond the already-broken TLS; base path mappings are untouched.
181
+
126
182
  ## Step 7 — Check alias coverage before any CloudFront swap
127
183
 
128
184
  CloudFront **rejects** an update if any alias on the distribution is not covered by the new cert.
@@ -155,11 +211,22 @@ echo | openssl s_client -servername HOST -connect HOST:443 2>/dev/null \
155
211
  | openssl x509 -noout -issuer -enddate
156
212
  ```
157
213
 
214
+ `openssl` shows you the cert, but it does **not** prove a client can connect. Add a real verify —
215
+ and treat a known-bad host as the control that proves your check actually fails:
216
+
217
+ ```
218
+ curl -sS -o /dev/null -w "http=%{http_code} ssl=%{ssl_verify_result}\n" https://HOST/
219
+ ```
220
+
221
+ A live expired cert returns `http=000` with `SSL certificate problem: certificate has expired`.
222
+
158
223
  Also run it **without** `-servername` to check the no-SNI / default-cert path — that path can still
159
224
  be serving the old cert after everything else looks fine.
160
225
 
161
- Finish by re-running the step 1 audit: every CloudFront distribution, every ALB listener, and every
162
- EB saved config should name a cert with a future `NotAfter`.
226
+ Finish by re-running the step 1 audit: every CloudFront distribution, every ALB listener, every
227
+ EB saved config **and every API Gateway custom domain** should name a cert with a future
228
+ `NotAfter`. A sweep that omits any one of those four resource types is not a clean result — it is
229
+ an untested one.
163
230
 
164
231
  ## Gotchas
165
232
 
@@ -170,9 +237,28 @@ EB saved config should name a cert with a future `NotAfter`.
170
237
  - **CloudFront takes one cert; ALBs take many.** Two completely different risk profiles in the same
171
238
  rotation — plan them separately.
172
239
  - **Wildcards match one label only.** Verify alias coverage with code, not by eye.
240
+ - **API Gateway custom domains are a fourth resource type.** A CloudFront + ALB + EB sweep misses
241
+ them completely. Find them by `execute-api` CNAMEs in Route 53. See step 6b.
242
+ - **An unknown account ID in `InUseBy` may be AWS itself.** `250044486744` is AWS-managed edge
243
+ infrastructure for API Gateway, not a rogue account. See step 6b.
244
+ - **The `GoAgilant-Developers` role needs `apigateway:GET` + `apigateway:PATCH`** on
245
+ `arn:aws:apigateway:*::/domainnames*`. Added to the permission set 2026-09-17; it was missing
246
+ during the rotation. Granted via IAM Identity Center, then **provisioned** to the account — the
247
+ provision step is what actually applies it. See step 6b.
248
+ - **Patch the field that matches the endpoint type.** REGIONAL uses `/regionalCertificateArn`,
249
+ EDGE uses `/certificateArn`. See step 6b.
250
+ - **`openssl` is not proof of health.** It prints the cert even when clients cannot connect. Use
251
+ `curl` and check for `http=000`. See step 9.
173
252
  - **Expired certs hide.** Sweep the whole account; do not trust `InUseBy`.
174
253
 
175
254
  ## Change history
255
+ - 2026-09-17 — Added step 6b (API Gateway custom domains) after `webhook.togahub.com` was found
256
+ serving an expired cert and failing TLS the day after the rotation reported all-clear; added the
257
+ `curl` proof step, the AWS-owned-account `InUseBy` tell, and the missing `apigateway` permission.
258
+ **Fixed the same day:** `apigateway:GET`/`PATCH` added to the permission set, domain confirmed
259
+ REGIONAL, `regionalCertificateArn` swapped to the Amazon-issued `*.togahub.com` cert
260
+ (`a53d1ff6-...`, expires 2027-03-31). Verified `http=404 ssl=0` (was `http=000 ssl=10`), with
261
+ `expired.badssl.com` as the control that still fails. (rgirish)
176
262
  - 2026-09-16 — Created from the account 654654170868 rotation: replaced an expiring 14-SAN IMPORTED
177
263
  cert with 6 per-domain Amazon-issued DNS-validated certs, relinked 12 ALB listeners, 6 CloudFront
178
264
  distributions and 11 EB environment configs; documented the EB saved-config trap. (rgirish)
@@ -6,8 +6,8 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-01
10
- owners: ["bala"]
9
+ updated: 2026-09-17
10
+ owners: ["bala", "rgirish"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - _underscore/Model/Client/TableView.php
@@ -15,6 +15,7 @@ files:
15
15
  related:
16
16
  - ../../../clients/compass-usa/features/item-fulfillment-tracking-tableview.md
17
17
  - ../../../clients/compass-usa/features/item-catalogs-and-duplicate-items.md
18
+ - ../../../clients/elite/features/supply2-tableview-config-drift.md
18
19
  - tableview-field-metadata.md
19
20
  ---
20
21
 
@@ -86,6 +87,34 @@ the existing Compass convention and the stored clauses stay uniform whether they
86
87
  or five. (The "expression field must not start with `(`" gotcha below is about the *field slot* of a
87
88
  condition **after** the wrapper is removed — the two are not in conflict.)
88
89
 
90
+ ### Subqueries ARE possible — use `/**/` in place of every space
91
+
92
+ The parser splits the stored clause on **literal spaces**, so a normal SQL subquery (which needs
93
+ spaces) cannot be stored in `apiWhereClause`. The way around it: write `/**/` instead of every
94
+ space. MySQL treats `/**/` as whitespace, and the parser sees no space at all. That makes an
95
+ arbitrary **correlated subquery** usable as the *field* side of a condition.
96
+
97
+ ```
98
+ (SELECT/**/c5.id/**/FROM/**/ItemClasses/**/c1/**/WHERE/**/c1.id=Items.itemClassId):notin:1:22:40
99
+ ```
100
+
101
+ Confirmed by reading `parseOptionsWhere` and simulating it in PHP (2026-09-17):
102
+
103
+ - Conditions split on `,` **only at matching paren depth** — commas nested deeper (e.g. inside a
104
+ `COALESCE(a,b,c)` within the subquery) are safe.
105
+ - Each condition then splits on `:` into field / operator / value.
106
+ - **The field side passes through to SQL unescaped**, so it can be any expression that has no
107
+ literal space, `:` or `,` at top depth.
108
+ - `notin` splits its value on `:`, so `:notin:1:22:40` becomes the IN-list `(1,22,40)`.
109
+
110
+ **Precedent:** Elite's prod clause on slug `item-fulfillments-for-sales-orders` already uses this
111
+ same `/**/` trick with a `CONCAT((SELECT ...))` subquery, so it is an established pattern, not a
112
+ one-off.
113
+
114
+ **Caveat — leave a comment.** This is powerful but it makes the stored clause hard to read for
115
+ anyone looking straight at `TableViews`. Always add a `--` comment in the migration explaining
116
+ what the subquery does.
117
+
89
118
  ### Join alias rule (which table-qualified name to use)
90
119
 
91
120
  In `_underscore/Model/Client/TableView.php` (~line 89-96), the **first** occurrence of a joined
@@ -129,12 +158,27 @@ None — engine behavior. A specific client's view may carry its own `apiWhereCl
129
158
  starts with a letter (e.g. `IFNULL(...)`), never a bare parenthesized expression.
130
159
  - **NULL FKs drop silently.** A plain `field:ne:x` excludes rows where `field IS NULL` too. Wrap
131
160
  nullable columns in `IFNULL(col,<sentinel>)` when the intent is "exclude only these values."
161
+ - **A literal space ends the clause element** — that is why a subquery needs `/**/` for every
162
+ space (see above). The same applies to any expression field: no spaces, anywhere.
163
+ - **Filter on ids, not names.** Names in lookup tables are free-text imported from NetSuite and
164
+ can be renamed without the id changing, so a `name LIKE` match is the fragile choice. Proven on
165
+ Elite: filtering root classes by `name LIKE '%Services%'` left 94 items visible instead of the
166
+ correct 82, because "Advisory and Modernization" contains no "Services".
132
167
  - **Operator allowlist.** `parseOptionsWhere` whitelists the operators above and rejects unknown
133
168
  ones (e.g. `regexp` returns a 500) — see the Compass cost-centers doc. Numeric/regex filtering
134
169
  that the grammar can't express must be done client-side or by adding an operator to api2.
135
170
 
136
171
  ## Change history
137
172
 
173
+ - 2026-09-17 — **Recorded that subqueries ARE possible in a stored `apiWhereClause`**: the parser
174
+ splits on literal spaces, so writing `/**/` in place of every space (MySQL reads it as
175
+ whitespace, the parser sees no space) lets an arbitrary correlated subquery act as the field
176
+ side of a condition. Confirmed the parser mechanics by reading `parseOptionsWhere` and
177
+ simulating it in PHP — commas split only at matching paren depth, the field side passes to SQL
178
+ unescaped, and `notin` splits its value on `:` so `:notin:1:22:40` yields the IN-list (1,22,40).
179
+ Noted the existing prod precedent (Elite's `item-fulfillments-for-sales-orders` clause already
180
+ uses `CONCAT((SELECT ...))` this way), and added the "filter on ids, not names" gotcha —
181
+ name-matching left 94 Elite items visible instead of 82. No code change. (rgirish)
138
182
  - 2026-09-01 — Recorded that the parser **strips one leading `(` and one trailing `)` from the whole
139
183
  clause before splitting**, so the wrapped single-condition form `(Items.catalogId:eq:1)` is safe
140
184
  and is the house convention (this was previously only documented for the multi-condition form).
@@ -6,7 +6,7 @@ project: Database Changes
6
6
  client: elite
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-04
9
+ updated: 2026-09-17
10
10
  owners: [tcox, bala, rgirish]
11
11
  files:
12
12
  - dbchanges2/Client_Elite/
@@ -14,6 +14,7 @@ files:
14
14
  - dbchanges2/Client_Elite/2026-08-17 - FulfillmentTableViews.sql
15
15
  - dbchanges2/Client_Elite/2026-08-13a - InventoryGroupingsSurfaceOverrides.sql
16
16
  - dbchanges2/Client_Elite/2026-09-04b - EliteInventoryHideServiceItems.sql
17
+ - dbchanges2/Client_Elite/2026-09-15a - InventoryHideServiceClassItems.sql
17
18
  - dbchanges2/Core/2026-08-07 - ServiceRequests TableView.sql
18
19
  - dbchanges2/Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql
19
20
  - dbchanges2/Client_Nychh/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql
@@ -25,6 +26,8 @@ related:
25
26
  - ../../../2.0/apps/_underscore/features/tracking-number-bridges.md
26
27
  - ../../../2.0/apps/_underscore/features/units-for-items-for-purchase-orders.md
27
28
  - ../../../2.0/apps/_underscore/features/page-meta-context-field-settings.md
29
+ - ../../../1.0/apps/library/features/netsuite-item-class-sync.md
30
+ - ../../../2.0/apps/_underscore/features/item-classification-itemclasses.md
28
31
  ---
29
32
 
30
33
  ## Summary
@@ -276,33 +279,106 @@ the live Units view is `inventory_units` (`TableViews` id 21), not
276
279
  This pairs with the existing team rule that **a committed migration records intent, not deployed
277
280
  state** — the frontend repo records the *default*, not this client's state.
278
281
 
279
- ## Hiding service lines from Inventory — `2026-09-04b`
282
+ ## Hiding service lines from Inventory — `2026-09-04b`, replaced by `2026-09-15a`
280
283
 
281
- `2026-09-04b - EliteInventoryHideServiceItems.sql` sets `TableViews.apiWhereClause`:
284
+ > **⚠ Current rule (2026-09-15): service items are hidden by their ROOT ItemClass, not by the
285
+ > `SVC-` partNumber prefix.** `2026-09-15a - InventoryHideServiceClassItems.sql` rewrites the
286
+ > `inventory_items` clause. The 2026-09-04b history below is kept because it explains the
287
+ > `IFNULL(...)` NULL-drop rule, which still applies.
288
+
289
+ `2026-09-15a - InventoryHideServiceClassItems.sql` rewrites `TableViews.apiWhereClause` for slug
290
+ `inventory_items` in `Client_Elite`. It walks the `ItemClasses` parent chain and excludes any item
291
+ whose **root** ItemClass id is **1** (Lifecycle Services), **22** (Advisory and Modernization) or
292
+ **40** (True Partner Services), using a correlated subquery written with `/**/` as whitespace —
293
+ see [apiWhereClause row filtering](../../../2.0/apps/api2/features/tableview-apiwhereclause-row-filtering.md#subqueries-are-possible--use--in-place-of-every-space)
294
+ for the technique and the parser mechanics.
295
+
296
+ **No `_underscore` deploy is needed.** The whole check lives in the stored clause. An earlier draft
297
+ required a `_isServiceClass` model field on `_Model_Elite_Item`; **that field does not exist and is
298
+ not needed** — do not add one.
299
+
300
+ ### Why the class check replaced the `SVC-` prefix
301
+
302
+ The `SVC-` prefix was an unreliable signal. Verified on **prod `Client_Elite`** (2026-09-15):
303
+
304
+ | Measure | Count |
305
+ |---|---|
306
+ | Items total | 122 |
307
+ | Service items by root ItemClass | 40 |
308
+ | Service items by `SVC-` prefix | 27 |
309
+ | Visible after the class filter | 82 |
310
+
311
+ The class check catches **18 services the prefix missed** — Cisco `CON-SNT-*` / `CON-ROB-*`
312
+ contracts, `LIC-ENT-*` licenses, `CarePacks - Fixed`, `PROJECT - CABLING`, `CONFIG/INSTALL` — with
313
+ **zero false hides**.
314
+
315
+ ### Match on ids, never on class NAMES
316
+
317
+ Hardcoded ids 1 / 22 / 40 are the stable reference. Names are free-text imported from NetSuite and
318
+ can be renamed without the id changing. Proven wrong in practice here: filtering on
319
+ `name LIKE '%Services%'` returned **94** visible instead of the correct **82**, because "Advisory
320
+ and Modernization" contains no "Services".
321
+
322
+ ### `Client_Elite` ItemClasses hierarchy (prod, verified 2026-09-15)
323
+
324
+ The table is **`ItemClasses`**, not `ItemClassifications`. Exactly **4 root rows**
325
+ (`parentItemClassId IS NULL`):
326
+
327
+ | id | Name | Kind |
328
+ |---|---|---|
329
+ | 1 | Lifecycle Services | service |
330
+ | 4 | Technology Sales | product |
331
+ | 22 | Advisory and Modernization | service |
332
+ | 40 | True Partner Services | service |
333
+
334
+ Tree depth is exactly **5 levels**, and every 5-level chain terminates at a true root (a query for
335
+ level-5 classes with a non-null parent returned 0 rows). So the migration's fixed 5-alias
336
+ `LEFT JOIN` walk resolves every item to its true root **today**. See
337
+ [the hierarchy import](../../../1.0/apps/library/features/netsuite-item-class-sync.md).
338
+
339
+ ### Unclassified items stay visible — and 5 real services leak
340
+
341
+ **29 Items have `itemClassId` NULL.** The clause is a blocklist, so unclassified items stay
342
+ **visible** — the safer default, since hiding stock people expect to see is worse than showing a
343
+ few extra lines. But 5 of those 29 are real services by name and will newly appear in Inventory:
344
+
345
+ - `SVC-FS-SHIPPING-RECLAIM-LAPTOP`
346
+ - `SVC-FS-SHIPPING-SYSTEM SWAP-LAPTOP`
347
+ - `SVC-FS-DEPLOY-GW`
348
+ - `SVC-FS-SHIPPING-2DAY`
349
+ - `SVC-FS-ELITE-PERIPHERAL`
350
+
351
+ **Open decision left with the developer:** either set an ItemClass on those 5 in NetSuite, or AND
352
+ the old `SVC-` prefix check back into the clause.
353
+
354
+ ### The previous rule — `2026-09-04b` (superseded for `inventory_items`)
355
+
356
+ `2026-09-04b - EliteInventoryHideServiceItems.sql` set:
282
357
 
283
358
  | View | Clause |
284
359
  |---|---|
285
- | `inventory_units` | `(IFNULL(Items.assetTypeId,0):ne:2)` |
286
- | `inventory_items` | `(Items.partNumber:excludes:SVC-,IFNULL(Items.assetTypeId,0):ne:2)` |
360
+ | `inventory_units` | `(IFNULL(Items.assetTypeId,0):ne:2)` — **still in force** |
361
+ | `inventory_items` | `(Items.partNumber:excludes:SVC-,IFNULL(Items.assetTypeId,0):ne:2)` — replaced |
287
362
 
288
- `assetTypeId 2` = Elite's `Services` row. Notes for anyone porting this:
363
+ `assetTypeId 2` = Elite's `Services` row. Notes that still apply to anyone porting this:
289
364
 
290
365
  - **The filter belongs in the database table view, not the frontend** — an explicit scope decision.
291
366
  - **`IFNULL(...,0):ne:2` is mandatory, not cosmetic.** A bare `:ne:2` drops every `NULL` row and
292
- would hide **all unstamped items** — and 110 of Elite's 113 items are unstamped until the cursor
293
- backfill runs. The shape is copied from Elite's existing `item-fulfillments-for-sales-orders`
294
- view. Grammar and the NULL-drop rule:
367
+ would hide **all unstamped items**. The shape is copied from Elite's existing
368
+ `item-fulfillments-for-sales-orders` view. Grammar and the NULL-drop rule:
295
369
  [apiWhereClause row filtering](../../../2.0/apps/api2/features/tableview-apiwhereclause-row-filtering.md).
296
- - **The `SVC-` prefix rule is kept on `inventory_items`** because it still catches the three items
297
- NetSuite mis-types as `InvtPart` (`SVC-TS-ELITE-HDONBOARDING`, `CONFIG/INSTALL`,
298
- `CONF-RM-INSTALL-SUPP`) — see
299
- [netsuite-togasupply-sync](./netsuite-togasupply-sync.md).
300
370
  - **Both `UPDATE`s are guarded on the current value**, so a re-run is a no-op.
301
371
  - `inventory_units` was targeted **because of the `SurfaceOverrides` finding above** — filtering
302
372
  view 17 would have had no visible effect for Elite.
303
373
 
304
374
  ## Gotchas / known issues
305
375
 
376
+ - **A 6th NetSuite class level would silently leak services back into Inventory.** The
377
+ `2026-09-15a` clause walks a **fixed 5-alias** chain because Elite's tree is exactly 5 deep
378
+ today — but nothing in the schema caps the depth. If NetSuite adds a level under a service root,
379
+ those items reappear in Inventory with no error. Re-check the depth whenever the classification
380
+ tree changes.
381
+
306
382
  - **A `Client_*` DB that predates a platform migration fails silently until a user opens the
307
383
  view.** Nothing validates `TableViewJoins.parentRecordFieldId` against live `Core.RecordFields`,
308
384
  so a deleted id sits there until it 500s. Worth checking for **any** client onboarded before
@@ -312,6 +388,18 @@ state** — the frontend repo records the *default*, not this client's state.
312
388
  [error-reporting-issue-event](../../../2.0/apps/_underscore/features/error-reporting-issue-event.md).
313
389
 
314
390
  ## Change history
391
+ - 2026-09-17 — **Replaced the `SVC-` partNumber prefix filter with an ItemClass-hierarchy check**
392
+ on the `inventory_items` view (`2026-09-15a - InventoryHideServiceClassItems.sql`): the clause
393
+ now walks the `ItemClasses` parent chain and excludes items whose ROOT class id is 1, 22 or 40,
394
+ via a `/**/`-whitespace correlated subquery. Prod numbers: 122 items, 40 service by class vs 27
395
+ by prefix — the class check catches 18 the prefix missed (Cisco CON-SNT-*/CON-ROB-*, LIC-ENT-*,
396
+ CarePacks, PROJECT - CABLING, CONFIG/INSTALL) with zero false hides; 82 visible after filtering.
397
+ **No `_underscore` deploy** — an earlier draft's `_isServiceClass` model field does not exist and
398
+ is not needed. Recorded the verified `Client_Elite` hierarchy (4 roots, exactly 5 levels, every
399
+ chain terminating at a true root) and the decision to match on **ids, not names** (a
400
+ `name LIKE '%Services%'` filter left 94 visible, not 82, because "Advisory and Modernization"
401
+ has no "Services" in it). Open item: 29 items have a NULL `itemClassId` and stay visible by
402
+ design, but 5 of them are real services and will newly appear. (rgirish)
315
403
  - 2026-09-04 — **Corrected which table view Elite's Inventory page actually reads, then filtered
316
404
  it.** `toga25-supply/src` shows the DEFAULT groupings using `inventory_items` +
317
405
  `units-for-items-for-purchase-orders`, and `inventory_units` appears nowhere in `src` — reading as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.833",
3
+ "version": "1.0.835",
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",