toga-ai 1.0.797 → 1.0.799

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-08
9
+ updated: 2026-09-11
10
10
  owners: [jcardinal, mhammontree, bala]
11
11
  files:
12
12
  - library/app/api/toga2.php
@@ -79,6 +79,17 @@ belong to the NetSuite importer — documented in the per-client-sync doc, not h
79
79
  - Returns the decoded response object; callers read `->data->{resource}`, `->meta->nextPage`,
80
80
  `->isSuccess`, `->status`, `->messages[].code`.
81
81
 
82
+ > **The "Invalid API Response" exception now LEADS with the api2 message codes (2026-09-11).**
83
+ > `send()`'s failure exception used to `print_r` the whole response envelope, whose header prints
84
+ > first — so on a real rejection the useful api2 code (e.g. `EV-10: ...`) fell **past** the
85
+ > `Logs.Event.errorMessage` `varchar(255)` limit and was **truncated away**, making the failure look
86
+ > "quiet" (this is exactly what hid the NYCHH duplicate-`Units` EV-10 — see the
87
+ > [per-client sync doc](../../worker/features/netsuite-togasupply-per-client-sync.md)). The exception
88
+ > message now puts the `->messages[].code: text` list **first**, via a new private helper
89
+ > `describeApiMessages()` (~L726), then the `print_r` dump. The helper **caps each message's text at
90
+ > 160 chars** so an echoed customer value cannot land in full in the log table (cso point). Net: the
91
+ > real api2 cause self-reveals in `Logs.Event` on the next run instead of being cut off.
92
+
82
93
  > **`send()` ALWAYS returns something carrying `isSuccess` — so `empty($response->isSuccess)` is a
83
94
  > safe failure check on anything it returns, GETs included** (verified 2026-08-12, ~L432–450). It
84
95
  > returns early **only** when `isSuccess` is true; when `isSuccess` is false and
@@ -413,6 +424,15 @@ enable flags** and an optional `$monitorTogadeskDepartmentIds[]`:
413
424
 
414
425
  ## Change history
415
426
 
427
+ - 2026-09-11 — **Observability: `send()`'s "Invalid API Response" exception now leads with the api2
428
+ message codes.** Added a private `describeApiMessages()` helper (~L726) that renders the
429
+ `->messages[].code: text` list (each text capped at 160 chars, cso point) and prepended it to the
430
+ exception message ahead of the `print_r` dump (~L636). Reason: `Logs.Event.errorMessage` is
431
+ `varchar(255)` and the old dump printed the envelope header first, pushing the real api2 code (e.g.
432
+ `EV-10`) past 255 chars where it was truncated — so failures looked "quiet". This made the NYCHH
433
+ duplicate-`Units` EV-10 self-reveal; the underlying case-mismatch fix and the three diagnosis traps
434
+ are on the [per-client sync doc](../../worker/features/netsuite-togasupply-per-client-sync.md). Library
435
+ only, `php -l` clean; **written, not committed/deployed**. (jcardinal)
416
436
  - 2026-09-08 - Added `updateTransferOrderStageToClosed(&$nsOrder, array &$clientConfiguration): bool`
417
437
  and the constant `TRANSFER_ORDER_STATUS__CLOSED = 'closed'` (also now used by the `case 'Closed'` in
418
438
  the transfer-order status switch). It is a **stage-only** writer: it never touches line items, so it
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-09
9
+ updated: 2026-09-11
10
10
  owners: ["dfranks", "bala", "jcardinal", "mhammontree", "snaredla", "rgirish"]
11
11
  files:
12
12
  - worker/crons/toga2/netsuite/common_sync_togasupply.php
@@ -296,6 +296,49 @@ hit **every section and every client**, not just the by-reference ones.
296
296
  GET's UTC. `strtotime()` and `App_Date::convertToSQLDate()` both use the script tz (Chicago) and are
297
297
  the trap. Any manual cursor rewind value you set by hand must also be **Eastern**.
298
298
 
299
+ ### ⚠ ITEM_RECEIPTS froze at RUNNING on a case-mismatched Units serial → duplicate-unit INSERT (EV-10 / 1062) (found + fixed 2026-09-11, NYCHH prod)
300
+
301
+ `syncItemReceiptFromNetsuite()` (`library/app/api/toga2.php` ~L4022) builds a PHP array of the
302
+ client's existing `Units` keyed by **serial number** to decide PUT (update) vs POST (create). The
303
+ DB `GET /units` (`Units.serialNumber IN (...)`) is **case-insensitive** and returns the **stored**
304
+ casing (e.g. `'na100'`), but the code keyed the PHP lookup by that **raw stored** value while reading
305
+ it with the **uppercased** serial (`strtoupper` at ~L3905). **PHP array keys are case-sensitive** → the
306
+ lookup missed the existing row → took the **create** path → `POST /units` collided on the
307
+ case-insensitive `Units.itemId_serialNumber` unique key → MySQL **1062** / api2 **EV-10** → the
308
+ ITEM_RECEIPTS section **threw before its cursor PUT**, freezing at RUNNING (and ITEM_FULFILLMENTS,
309
+ which runs behind it, froze with it).
310
+
311
+ - **Proven on NYCHH:** `Client_Nychh.Units` 23400-23409 store serial `'na100'..'na109'` (lowercase),
312
+ `c_netsuiteInternalInventoryAssignmentId` NULL; the sync sent `'NA100'`.
313
+ - **Fix (library only, `php -l` clean):** `strtoupper()` on **both** the array key **and** the reads,
314
+ so the PHP lookup is case-insensitive like the DB. A **second, half-fixed copy** of the same lookup
315
+ lower in the file (~L6326) already uppercased the **key** but not its two **reads** — made them
316
+ match too. php-reviewer + cso both cleared it (SAFE TO SHIP). **Written, NOT committed/deployed.**
317
+ - **Lesson:** whenever a PHP lookup mirrors a case-insensitive DB column, normalize case on **every**
318
+ side — the key write and every read. One un-normalized read reintroduces the duplicate-create path.
319
+
320
+ #### Diagnosis: three traps that hid this EV-10 (2026-09-11)
321
+
322
+ The section looked "quiet" (RUNNING, no loud crash-loop under the usual filter) because of three
323
+ things worth knowing for any 1.0-sync freeze:
324
+
325
+ - **areaPath is NOT only `crons/toga2/netsuite`.** 1.0 sync NetSuite/api2 errors also log under
326
+ `context.REFERENCE.areaPath` = `library/app/api` and `library/app/api/netsuite` — filtering on the
327
+ cron path alone **misses** them. (The "Filter on areaPath = `crons/toga2/netsuite`" note near the top
328
+ of this doc is therefore an *incomplete* filter — widen it to these two areas as well.)
329
+ - **EV-10 is api2's GENERIC "save failed" wrapper — the real DB error is NOT in base `Logs`.** The
330
+ underlying `1062 Duplicate entry ... for key ...` lives in `Logs_<Client>.Api`: filter
331
+ `responsePayload LIKE '%EV-10%'` (or join by `transactionId`) for route + `requestPayload` + the full
332
+ `responsePayload` carrying the real MySQL error. (Same `Logs_<Client>.Api` chain as the other rejection
333
+ gotchas in this doc.)
334
+ - **Some crashes never record at all.** The worker's error-log write to the logs DB can time out
335
+ (`mysqli_select_db ... errno=110 Connection timed out`, e.g. `Logs.Issue` 588/589), so a section can
336
+ freeze with **no** `Logs.Event`/`Issue` row — read `Client_<X>.Parameters` for the stuck RUNNING
337
+ section as the primary signal, not the log tables.
338
+ - Cross-link: the `send()` "Invalid API Response" exception was also changed 2026-09-11 to **lead with
339
+ the api2 message codes** so this EV-10 self-reveals in `Logs.Event` next time — see
340
+ [App_Api_Toga2](../../library/features/toga2-api-client-and-bridge.md).
341
+
299
342
  ### ⚠ Per-record isolation is SECTION-SPECIFIC — and SALES_ORDERS has NONE (corrected 2026-08-17)
300
343
 
301
344
  Whether a bad record is "stepped over" or freezes the section depends entirely on whether **that
@@ -1439,6 +1482,19 @@ library (or vice versa) crashes GroWrk and Adyen on their next sync run.
1439
1482
 
1440
1483
  ## Change history
1441
1484
 
1485
+ - 2026-09-11 — **ITEM_RECEIPTS froze at RUNNING on NYCHH: a case-mismatched `Units` serial lookup
1486
+ (library, fix written not deployed).** `syncItemReceiptFromNetsuite` (`library/app/api/toga2.php`
1487
+ ~L4022) keyed a PHP existing-`Units` array on the DB's stored serial casing (`'na100'`) but read it
1488
+ with the uppercased serial (`'NA100'`, `strtoupper` ~L3905); PHP array keys are case-sensitive, so the
1489
+ lookup missed the row, took the create path, and hit the case-insensitive `Units.itemId_serialNumber`
1490
+ unique key → MySQL **1062** / api2 **EV-10** → the section threw before its cursor PUT and froze,
1491
+ dragging ITEM_FULFILLMENTS behind it. Fixed by `strtoupper()`-ing **both** the array key and the reads
1492
+ (and the half-fixed second copy ~L6326, which had the key but not its two reads). Proven on
1493
+ `Client_Nychh.Units` 23400-23409 (serial `'na100'..'na109'`). php-reviewer + cso cleared it. Also
1494
+ recorded the three diagnosis traps that hid it: sync errors also log under areaPath `library/app/api`
1495
+ and `library/app/api/netsuite` (not only `crons/toga2/netsuite`); EV-10 is a generic wrapper whose real
1496
+ 1062 lives in `Logs_<Client>.Api` (`responsePayload LIKE '%EV-10%'`); and the worker's logs-DB write can
1497
+ time out (errno 110, `Logs.Issue` 588/589) so some crashes never record. (jcardinal)
1442
1498
  - 2026-09-09 — **The resume cursor was READ on the wrong clock — a silent +1h window skip dropped
1443
1499
  records across ALL 6 sections and ALL clients (worker, deployed + verified).** `startModeIteration`
1444
1500
  (`common_sync_togasupply.php` ~L622) parsed the Eastern-stored cursor with `strtotime()` (script tz
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-09-04
10
- owners: ["bala", "kyalamarthi"]
9
+ updated: 2026-09-11
10
+ owners: ["bala", "kyalamarthi", "rgirish"]
11
11
  files:
12
12
  - worker/ebs/cron.worker.php
13
13
  - worker/schedules/cron.worker.sync.json
@@ -23,6 +23,7 @@ related:
23
23
  - ../../library/features/cron-execution-monitoring.md
24
24
  - ../../library/features/email-test-mode-and-write-free-sends.md
25
25
  - ../../../1.0/standards/backend-php.md
26
+ - ../../../clients/prudential/features/itemclasses-device-tier-data.md
26
27
  ---
27
28
 
28
29
  ## Summary
@@ -47,6 +48,27 @@ frequency makes you read a normal gap as an outage.
47
48
  Also check whether the job is registered in the env you are testing in at all: this one is **not**
48
49
  in `cron.beta.json`, so beta never runs it on a schedule. Nothing on beta is evidence about it.
49
50
 
51
+ ### Is the job switched OFF? — read the `active` flag carefully
52
+
53
+ Each schedule entry is `{active, name, schedule, cron}`, and `ebs/cron.worker.php:39` gates it:
54
+
55
+ ```php
56
+ if (!property_exists($cron, 'active') || $cron->active) {
57
+ ```
58
+
59
+ - **`"active": 0` genuinely stops the job** — it is never written into the crontab, so it leaves
60
+ no `CronJobExecutions` row and no trace anywhere. Indistinguishable from "never fired" unless
61
+ you read the JSON.
62
+ - **⚠ A MISSING `active` property means the job RUNS.** The default is on, not off. Do not read
63
+ the absence of `active` as "disabled".
64
+
65
+ When a job is off, `git log` the schedule file to find out who turned it off and why — the commit
66
+ message usually carries the reason. Worked example (2026-09-11): "Transmissions to Netsuite"
67
+ (`cron.worker.sync.json:441`) is `"active": 0`, disabled 2026-07-17 in commit `14570def`
68
+ ("Add careOf & skip Apple; disable Netsuite cron") on `_production`. That single flag is what made
69
+ Prudential's `ItemClasses` data dead — see
70
+ [Prudential ItemClasses device-tier data](../../../clients/prudential/features/itemclasses-device-tier-data.md).
71
+
50
72
  ### Which of the 7 boxes runs it — and in which timezone
51
73
 
52
74
  Two facts you need before you go looking for evidence, both easy to get wrong:
@@ -188,6 +210,11 @@ real script **never executes**, on every tick, with no error anywhere. It looks
188
210
  contain anything; only the `cron` path is shell-parsed.
189
211
 
190
212
  ## Change history
213
+ - 2026-09-11 — Added **"is the job switched OFF?"** to Step 1: `ebs/cron.worker.php:39` gates on
214
+ `!property_exists($cron, 'active') || $cron->active`, so `"active": 0` genuinely stops the job
215
+ and leaves no trace, but a **missing `active` property means it RUNS** (default is on). Also:
216
+ `git log` the schedule file to find who disabled it and why. Found while proving Prudential's
217
+ `ItemClasses` data was dead. (rgirish)
191
218
  - 2026-09-04 — Added the **`&`-in-a-cron-filename trap**: `ebs/cron.worker.php:41` concatenates the
192
219
  path into the crontab line unquoted, so a shell metacharacter splits the command and the script
193
220
  never runs while leaving no trace. Found via the renamed Compass Canada
@@ -27,6 +27,7 @@
27
27
  | [FIELD_STORAGE fields — per-row lazy hydration and the platform-wide missing-column 500](features/field-storage-row-hydration.md) | `FIELD_STORAGE` is the 2.0 field type for blob-backed columns (S3 or local folder). |
28
28
  | [Forecast.Sales NetSuite import engine (real-time webhook)](features/forecast-sale-import.md) | Real-time importer that takes a NetSuite **sale** record and writes its lines into `Forecast.Sales` (the Forecast2 revenue table). |
29
29
  | [isFulfillable Propagation Up the SO↔PO Chain](features/fulfillable-item-propagation.md) | `Items.isFulfillable` is a boolean that gates whether a storefront line's **Qty Fulfilled** cell is actionable. |
30
+ | [ItemClasses / _Model_Client_ItemClass — who actually uses it (and why it is not free)](features/item-classification-itemclasses.md) | `ItemClasses` is an empty-looking, almost-unused lookup table that ships in **every** 2.0 client database. |
30
31
  | [Item-Fulfillment Stage Lifecycle (picked/packed/shipped) & Order Status](features/item-fulfillment-stage-lifecycle-and-order-status.md) | Every ItemFulfillment (IF) now carries an explicit **stage** — picked → packed → shipped — resolved through `ItemFulfillmentStages → ItemFulfillmentStatuses` (m |
31
32
  | [DB-free unit testing for _underscore model interceptors](features/model-interceptor-unit-testing.md) | `_underscore` shipped with **no** PHPUnit setup (no `composer.json`/`phpunit`; only vendored PhpOffice tests existed). |
32
33
  | [_Model magic-field access (__get without __isset)](features/model-magic-field-access.md) | `_Model` exposes DB columns as "magic" properties via `__get()`, but it defines **no** `__isset()`. |
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-14
10
- owners: ["jcardinal", "bala"]
9
+ updated: 2026-09-10
10
+ owners: ["jcardinal", "bala", "rgirish"]
11
11
  files:
12
12
  - _underscore/Cloud.php
13
13
  related:
@@ -59,6 +59,13 @@ None — shared core helper.
59
59
  do not add your own `ContinuationToken` loop on top.
60
60
  - A bucket outside the config-default us-east-1 (e.g. `agilant-as2` in us-west-2) throws
61
61
  `AuthorizationHeaderMalformed` unless you pass `$awsRegion`.
62
+ - **⚠ `copyFileToS3` is a single `putObject` — it CANNOT upload a file larger than 5 GB.**
63
+ Verified 2026-09-10 at `_underscore/Cloud.php:49`: the method issues one `putObject` call, and
64
+ S3 caps a single PUT at **5 GB**. There is **no `MultipartUploader` anywhere in `_underscore`
65
+ or `worker2`**. Anything that could produce a large artifact — a database dump above all — will
66
+ fail on this helper once it crosses the limit. If you need large-object upload, add
67
+ `MultipartUploader` explicitly; do not assume this helper scales.
68
+
62
69
  - **`getFileFromS3` (the GET method) is single-region.** It builds the `S3Client` with region
63
70
  `_Config::cloud('aws_region')` (the worker's own region) and only swallows HTTP 404. Reading
64
71
  an object from a bucket in a **different** region raises a `301 PermanentRedirect`, which it
@@ -67,6 +74,10 @@ None — shared core helper.
67
74
  has no region-override parameter.)
68
75
 
69
76
  ## Change history
77
+ - 2026-09-10 — Recorded that **`copyFileToS3` (`Cloud.php:49`) is a single `putObject`**, so it
78
+ cannot upload an object larger than S3's 5 GB single-PUT cap, and that **no `MultipartUploader`
79
+ exists anywhere in `_underscore` or `worker2`**. Matters for any large-artifact upload, database
80
+ dumps first. (rgirish)
70
81
  - 2026-07-15 — Documented that `getFileFromS3` is single-region (client built with the worker's
71
82
  own `aws_region`, only swallows 404, re-throws `301 PermanentRedirect` for cross-region reads);
72
83
  use a direct HTTPS download for cross-region/public objects. (bala)
@@ -0,0 +1,127 @@
1
+ ---
2
+ title: "ItemClasses / _Model_Client_ItemClass — who actually uses it (and why it is not free)"
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-09-11
10
+ owners: ["rgirish"]
11
+ files:
12
+ - _underscore/Model/Client/ItemClass.php
13
+ - _underscore/Model/Client/Item.php
14
+ - worker/crons/toga2/prudential/transmissions_to_netsuite.php
15
+ - toga2-supply/src/pages/Inventory/api/InventoryApi.tsx
16
+ related:
17
+ - ./netsuite-rest-client.md
18
+ - ./record-change-audit-log.md
19
+ - ../../../../clients/prudential/features/itemclasses-device-tier-data.md
20
+ - ../../dbchanges2/workflows/client-schema-drift-audit.md
21
+ ---
22
+
23
+ ## Summary
24
+
25
+ `ItemClasses` is an empty-looking, almost-unused lookup table that ships in **every** 2.0 client
26
+ database. It looks like free real estate for a new item-classification feature. It is not:
27
+ **2 of 36 production clients already have rows in it, and they use it for two different things,
28
+ neither of which is NetSuite's `class` field.**
29
+
30
+ Read this before you plan to reuse, extend, or repurpose `ItemClasses` for any client.
31
+
32
+ Audit done 2026-09-11 across all TOGA codebases and all 36 production client schemas.
33
+
34
+ ## Key files / entry points
35
+
36
+ - **`_underscore/Model/Client/ItemClass.php`** — `_Model_Client_ItemClass`,
37
+ `DATABASE = _underscore::DB_CLIENT`, `TABLE = 'ItemClasses'`. Four fields only:
38
+ `id`, `uuid`, `code`, `name`.
39
+ - **`_underscore/Model/Client/Item.php:23`** — the only foreign key:
40
+ `public $itemClassId = [self::FIELD_FOREIGNKEY, ... => '\_Model_Client_ItemClass'];`
41
+ - **`worker/crons/toga2/prudential/transmissions_to_netsuite.php:171-178`** — the **only** code
42
+ anywhere that reads it (see below).
43
+
44
+ ## How it works
45
+
46
+ ### The table ships blank to everyone
47
+
48
+ `ItemClasses` is part of the blank client DB, so every 2.0 tenant has the table. Having the
49
+ table says nothing about whether the client uses it. **Check row counts, not schema.**
50
+
51
+ ### Who has data (prod, verified 2026-09-11)
52
+
53
+ | Client | Rows in `ItemClasses` | Items tagged | What the rows actually mean |
54
+ |---|---|---|---|
55
+ | **Northwell** | 27 | 245 of 247 | ServiceNow **service categories** — "Print", "Computer", "Service Desk" |
56
+ | **Prudential** | 7 | 9 of 765 | Dell/Apple **device tiers** — "LATITUDE 73XX", "MACBOOK PRO" |
57
+ | all other 34 clients | 0 | 0 | unused |
58
+
59
+ Two clients, two unrelated meanings. Neither is an "item class" in the NetSuite sense.
60
+
61
+ **Canon is a false positive.** `dbchanges2/Client_Canon/2024-01-01.sql` contains `ItemClasses`
62
+ schema changes, but production `Client_Canon` has **0 rows**. Schema history is not usage.
63
+
64
+ ### There is exactly one reader in the whole platform
65
+
66
+ `worker/crons/toga2/prudential/transmissions_to_netsuite.php:171-178` pulls
67
+ `ItemClasses.name` as `deviceTier` for the line-1 item of a sales order and sends it to NetSuite:
68
+
69
+ ```sql
70
+ SELECT ItemClasses.name
71
+ FROM SalesOrderItems
72
+ INNER JOIN Items ON Items.id = SalesOrderItems.itemId
73
+ INNER JOIN ItemClasses ON ItemClasses.id = Items.itemClassId
74
+ WHERE SalesOrderItems.salesOrderId = SalesOrders.id AND SalesOrderItems.lineNumber = 1
75
+ LIMIT 1
76
+ ```
77
+
78
+ That cron is **disabled** — see the
79
+ [Prudential ItemClasses data doc](../../../../clients/prudential/features/itemclasses-device-tier-data.md).
80
+
81
+ **Nothing writes `itemClassId`.** No writer exists in `api2`, `worker2`, or `_underscore`.
82
+
83
+ ### Frontend: one real reference, and a grep trap
84
+
85
+ `grep itemClass` in the frontend is misleading — most hits in `toga2-supply` are CSS
86
+ variables like `menuItemClassNames`, not this table. The one genuine reference is
87
+ `toga2-supply/src/pages/Inventory/api/InventoryApi.tsx:815`, requesting the column
88
+ `item.itemClass.name`.
89
+
90
+ ## NetSuite `classification` — the shape you would have to map from
91
+
92
+ Queried live via SuiteQL (2026-09-11): **311 classifications**, columns
93
+ `id`, `name`, `fullname`, `isinactive`, `parent`.
94
+
95
+ - **It is a tree, not a flat list.** Each row has a `parent` and a colon-separated `fullname`,
96
+ e.g. name `Laptop` / fullname `Technology Sales : Hardware : End User Devices : Laptop` /
97
+ parent `405`.
98
+ - **⚠ `name` is NOT globally unique** — only unique within its parent. Repeats seen:
99
+ "Managed Services" 3x (ids 75, 395, 379), "Software" 2x (ids 3, 419), "AI & Data Sciences" 2x,
100
+ "Data Services" 2x.
101
+
102
+ ## Gotchas
103
+
104
+ - **⚠ `ItemClasses.name` has a UNIQUE index (index literally named `name`).** NetSuite
105
+ classification names repeat, so **any attempt to load NetSuite classes into this table fails
106
+ until that UNIQUE is dropped.** This is the first blocker on any NetSuite-classification work.
107
+ - **A hierarchy path column must be `TEXT`, not `varchar(512)`.** NetSuite `fullname` values are
108
+ long, and the 2.0 standard caps varchar at 255.
109
+ - **The table is already `utf8mb4_0900_ai_ci`**, so new columns need no explicit `COLLATE`.
110
+ - **`c_` custom fields are NOT declared as properties on the 2.0 Model class.** The framework
111
+ reads them from `CustomRecordFields` (`ItemClasses` is `Core.Records` id **105**). Confirmed:
112
+ no `public $c_netsuite*` property exists on any `Model/Client` class. Copy the pattern in
113
+ `dbchanges2/_modules/netsuite/2026-08-18 - TransferOrdersNetsuiteStatus.sql`.
114
+ - **Check whether the client has an `ItemClassCustomFields` table before adding `c_` columns.**
115
+ `Client_Prudential` does not, so its `c_` columns would go directly on `ItemClasses`.
116
+ - **"Every client has the table" ≠ "the table is free."** Repurposing `ItemClasses` org-wide
117
+ would break Northwell's 245 tagged items, which hold ServiceNow service categories.
118
+
119
+ ## Change history
120
+ - 2026-09-11 — Documented from a full usage audit of `ItemClasses` across every TOGA codebase and
121
+ all 36 prod client schemas: the table ships blank to all clients but **only Northwell (27 rows,
122
+ ServiceNow service categories) and Prudential (7 rows, Dell device tiers) have data**, for two
123
+ different meanings; there is exactly **one reader** (`transmissions_to_netsuite.php:171-178`,
124
+ currently disabled) and **zero writers**; Canon has schema but no rows. Recorded the live
125
+ NetSuite `classification` shape (311 rows, a tree with repeating `name`) and the resulting
126
+ blocker: `ItemClasses.name` is UNIQUE and must be dropped before NetSuite classes can be
127
+ mapped in. (rgirish)
@@ -6,8 +6,8 @@ project: AI-BDR
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-26
10
- owners: [tcox]
9
+ updated: 2026-09-10
10
+ owners: [tcox, rgirish]
11
11
  files:
12
12
  - bdr/PLAN.md
13
13
  - bdr/next.config.ts
@@ -32,6 +32,9 @@ files:
32
32
  - bdr/test/fastDial.test.ts
33
33
  - bdr/eslint.config.mjs
34
34
  - bdr/amplify.yml
35
+ - bdr/package.json
36
+ - bdr/.npmrc
37
+ - bdr/src/components/TalosDrawer.tsx
35
38
  - bdr/src/app
36
39
  - bdr/src/app/layout.tsx
37
40
  - bdr/src/app/page.tsx
@@ -619,6 +622,28 @@ would be cached by browsers and crawlers and become painful to undo.
619
622
 
620
623
  ## Gotchas
621
624
 
625
+ - **⚠ An `import` added without the matching `package.json` dependency only fails in Amplify —
626
+ never locally.** Root cause of the 2026-09-10 build break: PR #5 (`blox-talos-bdr-update`,
627
+ TRUE-81447) added
628
+ `import { TalosPanel } from "@agilant/toga-blox/dist/components/Talos/index.js"` to
629
+ `src/components/TalosDrawer.tsx` but **never added `@agilant/toga-blox` to `package.json`**.
630
+ The author's `node_modules` already had it (linked or left over from an earlier install), so
631
+ local `next build` was green; Amplify installs from the lockfile into a clean tree and failed
632
+ with `Module not found`. **Before merging any PR that adds a new bare import, grep the new
633
+ import specifiers against `package.json`** — a green local build proves nothing about a clean
634
+ install. The `local-link` workflow states the same trap from the blox side: "a link masks
635
+ missing dependency + peer declarations."
636
+ - **BDR's blox dependency set (as fixed 2026-09-10):** `@agilant/toga-blox@1.1.2-production.144`
637
+ (pinned exactly, from the `production` dist-tag), plus
638
+ `@fortawesome/fontawesome-svg-core@^7.3.1` and `@fortawesome/react-fontawesome@^3.5.0`, plus a
639
+ new `bdr/.npmrc`. Verified: clean install exit 0, `next build` compiled, **385/385 tests pass**.
640
+ The `.npmrc` needs the **FontAwesome Pro** registry line and `legacy-peer-deps=true` — blox is a
641
+ **public** package needing no token, and blox peers still cap React at `^18` while BDR runs
642
+ React 19. Full detail:
643
+ [dynamic-publish-pipeline.md](../../toga-blox/workflows/dynamic-publish-pipeline.md#consuming-blox-which-registry-actually-needs-a-token).
644
+ **Never commit the token value** — a live one was pushed in `bdr` commit `88809c3`; treat it as
645
+ compromised and rotate.
646
+
622
647
  - **"The funnel is gone / a CRM link doesn't prefill" — check the redirect FIRST.** Since
623
648
  2026-08-24 `/` 307s to `/landing`, so every symptom that used to mean "missing env" or
624
649
  "rate-limit soft-degrade" can now simply mean the request never reached `page.tsx`. Probe
@@ -754,6 +779,14 @@ would be cached by browsers and crawlers and become painful to undo.
754
779
  harness; pull it with `npx toga-ai`.
755
780
 
756
781
  ## Change history
782
+ - 2026-09-10 — **Fixed the Amplify `Module not found` build break** and recorded its durable
783
+ lesson: PR #5 added a `@agilant/toga-blox` import to `TalosDrawer.tsx` without adding the
784
+ package to `package.json`, so it built locally (stale `node_modules`) and failed only on
785
+ Amplify's clean install. Added `@agilant/toga-blox@1.1.2-production.144` +
786
+ `@fortawesome/fontawesome-svg-core@^7.3.1` + `@fortawesome/react-fontawesome@^3.5.0` and a
787
+ `bdr/.npmrc` (FontAwesome Pro registry + `legacy-peer-deps=true` for the React 19 host; blox
788
+ itself is public and needs no token). Verified clean install, compiled build, 385/385 tests.
789
+ (rgirish)
757
790
  - 2026-08-26 — Env-var accounting only: recorded an **optional 8th deploy var**,
758
791
  `TOGA_SECURITY_CAMPAIGN_UUID`, declared in `.env.example` and deliberately **left unset**.
759
792
  It gates `/landing`'s new real callback route (`POST /api/landing-call`), which 503s and
@@ -6,12 +6,14 @@ project: TOGa Blox
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-09-02
10
- owners: [jcardinal, apeterson, tcox]
9
+ updated: 2026-09-10
10
+ owners: [jcardinal, apeterson, tcox, rgirish]
11
11
  files:
12
12
  - toga-blox/.github/workflows/publish.yml
13
13
  - toga-blox/package.json
14
14
  - toga-blox/src/utils/getFontAwesomeIcon.tsx
15
+ - bdr/.npmrc
16
+ - toga25-supply/.npmrc
15
17
  related:
16
18
  - ../architecture.md
17
19
  - ./landing-a-large-feature-branch.md
@@ -83,6 +85,50 @@ blox channel. A channel exists **only** when this repo (`toga-blox-npm`) publish
83
85
  This publish-before-build ordering is the ongoing cost of the strict 1:1 model: every
84
86
  environment needs its own maintained `_<mode>` branch in `toga-blox-npm`.
85
87
 
88
+ ## Consuming blox: which registry actually needs a token
89
+
90
+ **`@agilant/toga-blox` is a PUBLIC npm package. It needs NO auth.** Verified 2026-09-10 by an
91
+ unauthenticated request:
92
+
93
+ ```
94
+ curl -s -o /dev/null -w '%{http_code}' https://registry.npmjs.org/@agilant%2Ftoga-blox
95
+ # 200
96
+ ```
97
+
98
+ The team had been copying an `.npmrc` carrying **two** tokens into each consumer, believing the
99
+ `@agilant` scope was private. It is not. Only **one** credential is real:
100
+
101
+ | Registry | Auth needed? | Why |
102
+ |---|---|---|
103
+ | `registry.npmjs.org` (`@agilant/toga-blox`) | **No** | Public package, HTTP 200 unauthenticated |
104
+ | `npm.fontawesome.com` (`@fortawesome/pro-*`) | **Yes** | Paid/private packages |
105
+
106
+ **FontAwesome Pro is the only thing the token is for.** blox's `src/utils/getFontAwesomeIcon.tsx`
107
+ hard-imports `@fortawesome/pro-solid-svg-icons`, `pro-regular-svg-icons` and
108
+ `pro-light-svg-icons`, so any consumer of blox pulls the Pro registry transitively. Proven by
109
+ isolating `HOME` to an empty dir and installing: the failure is **E401 on `npm.fontawesome.com`
110
+ tarballs only** — never on the `@agilant` scope.
111
+
112
+ Practical consequence: a consumer's `.npmrc` needs the FontAwesome registry line + token and
113
+ `legacy-peer-deps=true`. It does **not** need an `@agilant:registry` line or an npmjs token.
114
+
115
+ ### `legacy-peer-deps=true` is required on a React 19 host
116
+
117
+ blox `peerDependencies` still declare React `^18`. A React 19 consumer (e.g. the `bdr` Next.js
118
+ app) cannot resolve the install without `legacy-peer-deps=true` in `.npmrc`. The peer range is a
119
+ declaration, not a runtime wall — blox runs fine on React 19 (see
120
+ [local-link-into-a-consumer-app.md](./local-link-into-a-consumer-app.md) §2).
121
+
122
+ ### ⚠ Never commit a token VALUE in `.npmrc`
123
+
124
+ Live FontAwesome tokens have been found **committed** in `toga25-supply/.npmrc` and
125
+ `info/.npmrc`, and one was pushed in `bdr` commit `88809c3`. Treat every token found this way as
126
+ compromised and rotate it. The correct shape is an env-var placeholder resolved at build time —
127
+ which is exactly what blox's own `publish.yml` already does (see the "Secret hygiene done right"
128
+ gotcha below). This is the same violation already recorded in
129
+ [`../../../standards/frontend.md`](../../../standards/frontend.md); what is **new here is that
130
+ only the FontAwesome token was ever needed** — the npmjs token in those files bought nothing.
131
+
86
132
  ## Gotchas
87
133
 
88
134
  - **⚠ There is a STALE UNDERSCORED dist-tag on the registry — `_sandbox-client` (with the
@@ -212,6 +258,15 @@ environment needs its own maintained `_<mode>` branch in `toga-blox-npm`.
212
258
  you push to a release branch.
213
259
 
214
260
  ## Change history
261
+ - 2026-09-10 — **Corrected a long-standing team assumption: `@agilant/toga-blox` is a PUBLIC npm
262
+ package and needs no auth** (verified by unauthenticated `curl` → HTTP 200). The only registry
263
+ that requires a token is **FontAwesome Pro** (`npm.fontawesome.com`), pulled in transitively
264
+ because `getFontAwesomeIcon.tsx` hard-imports the `@fortawesome/pro-*` packages — proven by
265
+ isolating `HOME` and observing E401 on FontAwesome tarballs only. Consumers had been copying an
266
+ `.npmrc` with both tokens; the npmjs one was never needed. Also recorded that
267
+ `legacy-peer-deps=true` is required on a React 19 host (blox peers still cap at `^18`), and that
268
+ live tokens were found committed in `toga25-supply/.npmrc`, `info/.npmrc` and pushed in `bdr`
269
+ `88809c3` — rotate and replace with a build-time env-var placeholder. (rgirish)
215
270
  - 2026-09-02 — Released `1.1.2-sandbox-client.142` (base `1.1.0` → `1.1.1` on `_sandbox-client`,
216
271
  commit `543a7186`) and rolled it into `toga25-supply` (pin `1.1.1-sandbox-client.140` →
217
272
  `1.1.2-sandbox-client.142`, clean `npm install`, `tsc --noEmit` zero errors). The documented
@@ -6,13 +6,15 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-02
10
- owners: [jcardinal, dfranks, mhammontree, tcox, bala, ajean]
9
+ updated: 2026-09-10
10
+ owners: [jcardinal, dfranks, mhammontree, tcox, bala, ajean, rgirish]
11
11
  files:
12
12
  - worker2/Worker/
13
13
  - worker2/Controller/Index.php
14
14
  - worker2/LambdaFunctions/JobScheduler/lambda_function.py
15
15
  - _underscore/Worker.php
16
+ - worker2/Worker/Infrastructure/Database.php
17
+ - worker2/.platform/hooks/prebuild/020-install-mysql-client.sh
16
18
  related:
17
19
  - ../architecture.md
18
20
  - ./netsuite-opportunity-client-labels.md
@@ -190,6 +192,40 @@ The EB worker dispatcher (`Controller/Index.php::worker()`) wraps your action ca
190
192
  > It now captures **both** success results and failure text. See
191
193
  > [architecture.md](../architecture.md) for the multi-producer rename gotcha.
192
194
 
195
+ ### ⚠ A `null` return is logged as SUCCESS — "it ran green" is not evidence it did anything
196
+
197
+ `Controller/Index.php:378` records `isSuccess = 1` for **any** normal return, including `null`.
198
+ The framework has no notion of "the action did no work": an action that silently does nothing —
199
+ or is an unfinished stub — produces a clean `WorkerJobs` row indistinguishable from a real run.
200
+
201
+ Live example: **`_Worker_Infrastructure_Database::Backup()`
202
+ (`worker2/Worker/Infrastructure/Database.php:5-15`) is a stub.** It sends a test email to a
203
+ hardcoded address and returns `null`. Every invocation is logged as a **successful database
204
+ backup**. Nothing is backed up.
205
+
206
+ Two rules follow:
207
+
208
+ 1. **When you write an action, return something that proves the work happened** — a count, an
209
+ id list, a summary string. `return null;` on a work-performing action throws away the only
210
+ signal the dispatcher records.
211
+ 2. **When you audit an action, never accept a green `WorkerJobs` row as proof.** Read the
212
+ `output` column, and read the method. This applies to anything safety-critical (backups,
213
+ reconciliations, syncs) where "it's been green for months" is the expected — and worthless —
214
+ observation.
215
+
216
+ Two related defects found in the same audit, both worth knowing before you build on this
217
+ infrastructure:
218
+
219
+ - **`_underscore/Cloud.php:49` `copyFileToS3` is a single `putObject`**, so it cannot upload
220
+ anything over S3's **5 GB** single-PUT cap, and there is no `MultipartUploader` anywhere in
221
+ `_underscore` or `worker2` — see
222
+ [_Cloud S3 helpers](../../_underscore/features/cloud-s3-helpers.md).
223
+ - **`worker2/.platform/hooks/prebuild/020-install-mysql-client.sh` installs the WRONG client for
224
+ Aurora MySQL 3.** It installs **`mariadb105`**, whose `mysqldump` has no `--set-gtid-purged`
225
+ flag and emits MariaDB-versioned `/*!100xxx*/` conditional comments — comments that MySQL
226
+ silently **ignores on reload**, so parts of the dump vanish without any error. Any dump-based
227
+ work on this host needs the **MySQL** client (`mysql-community-client`), not the MariaDB one.
228
+
193
229
  Crucially, the worker **always returns HTTP 200, even on a throw**, so SQS deletes the message
194
230
  immediately. **There is no DLQ and no automatic retry.** A throw makes the failure *visible* (a
195
231
  failed `WorkerJobs` row to alert on) — it does **not** re-run the job.
@@ -464,6 +500,18 @@ Rules: rename **both sides in one PR and one deploy**; grep every repo for the o
464
500
  commit-before-SQS transaction pattern that the worker relies on.
465
501
 
466
502
  ## Change history
503
+ - 2026-09-10 — Recorded that **a `null` return is stamped `isSuccess = 1`**
504
+ (`Controller/Index.php:378`), so an action that does nothing is logged as a successful run —
505
+ with the live proof that `_Worker_Infrastructure_Database::Backup()`
506
+ (`Worker/Infrastructure/Database.php:5-15`) is a stub that emails a hardcoded address, returns
507
+ `null`, and has therefore been reporting successful database backups while backing up nothing.
508
+ Added the two rules: return a proof-of-work value from every action, and never treat a green
509
+ `WorkerJobs` row as evidence for a safety-critical job. Also recorded two neighbouring defects
510
+ found in the same audit: `_Cloud::copyFileToS3` is a single `putObject` (5 GB ceiling, no
511
+ `MultipartUploader` in the codebase), and the EB prebuild hook
512
+ `020-install-mysql-client.sh` installs **`mariadb105`**, whose `mysqldump` lacks
513
+ `--set-gtid-purged` and writes `/*!100xxx*/` comments that MySQL silently drops on reload —
514
+ the wrong client for Aurora MySQL 3. (rgirish)
467
515
  - 2026-09-02 — Added the corollary that an action must **never sleep-wait for a third-party API
468
516
  rate budget** (it burns the 300s cap and the failure is unrecoverable — no retry, no DLQ); bulk
469
517
  work yields and re-enqueues, event-driven work proceeds over budget counted and alarmed. (ajean)
@@ -18,10 +18,10 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
18
18
 
19
19
  ## 2.0 framework
20
20
 
21
- - **_underscore** (_Underscore) _(framework core)_ — 82 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
+ - **_underscore** (_Underscore) _(framework core)_ — 83 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 68 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 25 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
- - **dbchanges2** (Database Changes) _(framework core)_ — 17 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
24
+ - **dbchanges2** (Database Changes) _(framework core)_ — 18 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
25
25
  - **toga2-supply** (TOGa Supply) — 9 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
26
26
  - **saml** (SAML SSO Gateway) — 6 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
27
27
  - **toga2-view** (TOGa View Frontend) — 12 doc(s) → [2.0/apps/toga2-view/INDEX.md](2.0/apps/toga2-view/INDEX.md)
@@ -18,7 +18,7 @@ project: _Underscore
18
18
  client: nychh
19
19
  type: profile
20
20
  status: active
21
- updated: 2026-09-10
21
+ updated: 2026-09-11
22
22
  owners: ["jcardinal", "apeterson", "bala", "akhokhani"]
23
23
  files:
24
24
  - dbchanges2/Client_Nychh/2026-09-02a - TransferOrderNetsuitePushInterceptor.sql
@@ -115,6 +115,16 @@ table views. Client-specific DB change-sets live in `dbchanges2/Client_Nychh/`.
115
115
  written. Proven on IF `6138135` (Eastern 2025-09-04 17:26:58). Fixes and the standing "cursor is
116
116
  always Eastern" rule live in the
117
117
  [per-client sync feature doc](../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md).
118
+ - **ITEM_RECEIPTS froze on a case-mismatched serial → duplicate-`Units` EV-10/1062, root-caused + fixed
119
+ (2026-09-11, fix NOT deployed).** The receipts feed threw before its cursor PUT (freezing it and
120
+ ITEM_FULFILLMENTS behind it) because `syncItemReceiptFromNetsuite` keyed its existing-`Units` PHP
121
+ lookup on the DB's stored serial casing (`'na100'`) but read it with the uppercased serial (`'NA100'`);
122
+ the missed row took the create path and collided on the case-insensitive `Units.itemId_serialNumber`
123
+ unique key → MySQL 1062 / api2 EV-10. Proven on `Client_Nychh.Units` 23400-23409
124
+ (serials `'na100'..'na109'`, `c_netsuiteInternalInventoryAssignmentId` NULL). Library-only fix
125
+ (`strtoupper()` on both the key and the reads). This is a **different** duplicate from the open
126
+ sales-order `Items` duplicate below (658-BFZH). Mechanism + the EV-10 → `Logs_Nychh.Api` diagnosis
127
+ traps: [per-client sync doc](../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md).
118
128
  - **NYCHH sync scope + data-gap scale + backfill approach (2026-09-09).**
119
129
  - **Scope = 22 NetSuite customers:** parent **28908** "NYC Health + Hospitals" + 21 hospital child
120
130
  customers (`24145, 29273, 29276, 31584, 31910-31916, 31918-31925, 32229, 33674`).
@@ -5,6 +5,7 @@
5
5
  | [Prudential: Dell ASN units PRE/POST interceptor (legacy key + flat tracking)](features/dell-asn-units-interceptor.md) | 2.0 | After the tracking-number bridge migration, the ASN unit route was renamed (`advance-shipping-notice-units` → `advance-shipping-notice-item-units`), so the inhe |
6
6
  | [Prudential: Dell LCH IOP transmissions (LCHRequestV2)](features/dell-lch-iop-transmissions.md) | 1.0 | Outbound order transmissions from the 1.0 worker tier to Dell's Lifecycle Hub (LCH) ITSM integration. |
7
7
  | [Prudential: Device information import + unit→contact linking (import_device_information.php)](features/device-information-import-and-contact-linking.md) | 1.0 | Prudential's **device-sync** cron (`worker/crons/toga2/prudential/import_device_information.php`) pulls device/asset records (from ServiceNow / the Dell CMDB fe |
8
+ | [Prudential: ItemClasses device-tier data is dead (and how that was proved)](features/itemclasses-device-tier-data.md) | 2.0 | `Client_Prudential.ItemClasses` held 7 hand-seeded Dell/Apple **device tiers** ("LATITUDE 73xx", "MACBOOK PRO", …) tagged onto **9 of 765** items. |
8
9
  | [Prudential: OneUptime order-pipeline backlog monitors (Monitor/Prudential/*)](features/oneuptime-order-pipeline-monitors.md) | 2.0 | Three worker2 OneUptime push monitors (TRUE-80587) that watch the Prudential/Dell order pipeline for **stalls**, which were previously invisible — a stuck pipel |
9
10
  | [Prudential: order email PDF attachments (which guide goes on which email)](features/order-email-pdf-attachments.md) | 1.0 | Every customer-facing Prudential order email carries S3-hosted PDF guides, and **which** guide is attached is decided by the service request type. |
10
11
  | [Prudential: Service Request Regional Address Validation](features/service-request-address-validation.md) | 2.0 | The `prePost` interceptor on `_Model_Prudential_ServiceRequest` validates `deliverToAddress` fields differently depending on which Prudential regional customer |
@@ -0,0 +1,108 @@
1
+ ---
2
+ title: "Prudential: ItemClasses device-tier data is dead (and how that was proved)"
3
+ framework: "2.0"
4
+ repo: dbchanges2
5
+ project: Database Changes
6
+ client: prudential
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-09-11
10
+ owners: ["rgirish"]
11
+ files:
12
+ - dbchanges2/Client_Prudential/2024-01-01.sql
13
+ - dbchanges2/Client_Prudential/2026-09-11 - ClearItemClasses.sql
14
+ - worker/crons/toga2/prudential/transmissions_to_netsuite.php
15
+ - worker/schedules/cron.worker.sync.json
16
+ related:
17
+ - ../profile.md
18
+ - ../../../2.0/apps/_underscore/features/item-classification-itemclasses.md
19
+ - ../../../2.0/apps/_underscore/features/record-change-audit-log.md
20
+ - ../../../1.0/apps/worker/workflows/tracing-a-worker-cron-run-in-production.md
21
+ ---
22
+
23
+ ## Summary
24
+
25
+ `Client_Prudential.ItemClasses` held 7 hand-seeded Dell/Apple **device tiers**
26
+ ("LATITUDE 73xx", "MACBOOK PRO", …) tagged onto **9 of 765** items. The data was seeded once by
27
+ SQL in 2023, never maintained, is partly wrong, and **nothing reads or writes it today**. It was
28
+ cleared on 2026-09-11 to free the table for real NetSuite item classification later.
29
+
30
+ The second half of this doc is the **4-source method used to prove "this field is dead"** —
31
+ reusable for any field on any client.
32
+
33
+ ## How it works (what the data was)
34
+
35
+ Source is a hardcoded seed block in `dbchanges2/Client_Prudential/2024-01-01.sql:435-449`:
36
+ 7 `INSERT INTO ItemClasses`, then a per-`partNumber` `UPDATE` stamping `Items.itemClassId`.
37
+ **No API, no external feed, no sync.** That is the entire origin.
38
+
39
+ The one consumer was `worker/crons/toga2/prudential/transmissions_to_netsuite.php:171-178`,
40
+ which sent `ItemClasses.name` to NetSuite as `deviceTier`.
41
+
42
+ ## Proving a field is dead — the 4-source method
43
+
44
+ Run all four. Any one alone is weak; together they are conclusive.
45
+
46
+ **1. Grep for readers and writers in every repo.**
47
+ One reader (`transmissions_to_netsuite.php`), zero writers in `api2`, `worker2`, `_underscore`.
48
+ Watch for frontend grep noise — `itemClass` matches CSS variable names like `menuItemClassNames`.
49
+
50
+ **2. Check the field-change audit log — absence is evidence.**
51
+ Resolve the field id first (`Logs_*` and `Core` are on different clusters, so this is two
52
+ queries — see [Record Change Audit Log](../../../2.0/apps/_underscore/features/record-change-audit-log.md)):
53
+
54
+ - `Core.RecordFields` id **605** = `Items.itemClassId`; `Core.Records` id **21** = `Items`,
55
+ id **105** = `ItemClasses`.
56
+ - `Logs_Prudential.RecordField` has **zero rows** for field 605, ever. Zero log rows on record 105.
57
+
58
+ **Prove the logging itself works, or "no rows" means nothing.** Item logging is demonstrably
59
+ live: 1,963 log rows across 353 items, current through today, tracking `partNumber`,
60
+ `description`, `isFulfillable`, `catalogId`. So `itemClassId` simply never changes.
61
+
62
+ **3. Scan the inbound API payloads.**
63
+ Zero of **3,808** `/v2/items` calls in September carried an `itemClass` key. Items arrive from a
64
+ NetSuite sync cron — `POST /v2/items` from `34.214.204.95` (`apiId` 5) — with payload shape
65
+ `{partNumber, description, inventoryType, catalog:{name}, c_netsuiteInternalItemId, isFulfillable}`.
66
+ **No `itemClass` field.** That is why new items never get a class, and why only 9 of 765 are tagged.
67
+
68
+ **4. Check the consumer cron's `active` flag, then `git log` it.**
69
+ `worker/schedules/cron.worker.sync.json:441` — `"active": 0` on "Transmissions to Netsuite".
70
+ Disabled **2026-07-17** by Jeff Cardinal, commit `14570def` ("Add careOf & skip Apple; disable
71
+ Netsuite cron"), branch `_production`. See
72
+ [tracing a worker cron run](../../../1.0/apps/worker/workflows/tracing-a-worker-cron-run-in-production.md)
73
+ for the `active`-flag semantics.
74
+
75
+ ## Gotchas
76
+
77
+ - **⚠ The surviving tags were partly wrong.** 2 of the 9: item 3512 (`MRW23XX/A`,
78
+ "MacBook Pro 16 M3") was tagged **MACBOOK AIR**; item 3513 (`920-008219`, a Logitech keyboard)
79
+ was tagged **MACBOOK PRO**. Stale hand-seeded lookup data degrades silently — do not trust it
80
+ because it exists.
81
+ - **Clear the FK before the parent rows.** `Items_itemClassId` is the only FK referencing
82
+ `ItemClasses` (verified via `information_schema.KEY_COLUMN_USAGE` on `Client_Prudential`), so
83
+ the null-out of `Items.itemClassId` must run **before** the parent rows are removed.
84
+ - **Northwell was NOT touched.** Its 27 ServiceNow service categories and 245 tagged items are
85
+ still live. Open question, not yet decided: whether to move Northwell's rows to a separate
86
+ table if `ItemClasses` gets repurposed org-wide.
87
+
88
+ ## The migration
89
+
90
+ `dbchanges2/Client_Prudential/2026-09-11 - ClearItemClasses.sql` — data removal only, re-runnable,
91
+ table and columns untouched. Two statements: null out every non-null `Items.itemClassId`, then
92
+ empty `ItemClasses`. Order matters (see the FK gotcha above).
93
+
94
+ **Deliberately scoped small.** Adding the NetSuite classification columns
95
+ (`c_netsuiteInternalClassificationId`, `c_netsuiteFullName`, `c_netsuiteParentId`), dropping the
96
+ UNIQUE on `name`, and registering the `CustomRecordFields` rows for `recordId` 105 is a
97
+ **separate later task**. The traps already found for it are recorded in
98
+ [ItemClasses / _Model_Client_ItemClass](../../../2.0/apps/_underscore/features/item-classification-itemclasses.md).
99
+
100
+ ## Change history
101
+ - 2026-09-11 — Cleared the 7 stale device-tier rows and the 9 item tags via
102
+ `2026-09-11 - ClearItemClasses.sql` (null the FK first — `Items_itemClassId` is the only FK).
103
+ Documented the 4-source proof that the data was dead: one reader / zero writers,
104
+ zero `Logs_Prudential.RecordField` rows for `Core.RecordFields` id 605 against 1,963 live rows
105
+ on other Item fields, zero of 3,808 September `/v2/items` payloads carrying `itemClass`, and the
106
+ sole consumer cron `"active": 0` since 2026-07-17 (commit `14570def`). Also recorded that 2 of
107
+ the 9 surviving tags were plainly wrong. Column additions deliberately deferred to a later task.
108
+ (rgirish)
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: prudential
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-31
9
+ updated: 2026-09-11
10
10
  owners: ["rgirish", "bala"]
11
11
  files:
12
12
  - worker/crons/toga2/prudential/transmit_ordershipped_updates_prudential.php
@@ -86,20 +86,26 @@ TrackingNumbers WHERE purchaseOrderId = PurchaseOrders.id AND TrackingNumbers.st
86
86
  orders that genuinely qualified for a delivery email. Over 2026-07-01..07-09 this dropped 6 false
87
87
  positives (25 → 19 flagged) while retaining all 19 genuine discrepancies.
88
88
 
89
- ## A second cron sends the same shipped email
89
+ ## A second cron sends the same shipped email — but it is DISABLED (corrected 2026-09-11)
90
90
 
91
- `transmissions_to_netsuite.php` (also `active: 1`) builds and sends the **same** shipped email —
92
- same subjects, same branch shape — and also stamps `c_dtTransmittedOrderShippedUpdateToPrudential`.
93
- It is easy to miss: it is not named like the other `transmit_*` crons. Any change to the shipped
94
- email's content or attachments must be applied to **both** files or the two senders drift.
91
+ `transmissions_to_netsuite.php` builds and sends the **same** shipped email — same subjects, same
92
+ branch shape — and also stamps `c_dtTransmittedOrderShippedUpdateToPrudential`. It is easy to
93
+ miss: it is not named like the other `transmit_*` crons. Any change to the shipped email's content
94
+ or attachments must be applied to **both** files or the two senders drift.
95
+
96
+ > **⚠ It is `"active": 0`, not `active: 1` as this page said until 2026-09-11.**
97
+ > `worker/schedules/cron.worker.sync.json:441` — disabled **2026-07-17** by Jeff Cardinal, commit
98
+ > `14570def` ("Add careOf & skip Apple; disable Netsuite cron") on `_production`. So **it has not
99
+ > been sending anything since then**, and it is not currently a live duplicate sender. Keep the
100
+ > two files in sync anyway — it can be switched back on with one flag.
95
101
 
96
102
  **And it does not stop at the shipped email — it also sends the two *delivered* emails.**
97
103
  `transmissions_to_netsuite.php` carries "Prudential: New hire equipment has been delivered"
98
104
  (~L1765) and "Prudential: Your equipment has been delivered" (~L2149), the **same two subjects**
99
105
  that `transmit_closecomplete_updates_prudential.php` sends — except the `transmissions_to_netsuite`
100
- versions make **no `addAttachment()` calls at all**. Whether both crons fire for the same order was
101
- not established (2026-08-31); if they do, the customer receives a duplicate delivered email, one
102
- copy of which is missing the guides. Establish this before touching either delivered path.
106
+ versions make **no `addAttachment()` calls at all**. The open question from 2026-08-31 — whether
107
+ both crons fire for the same order and send a duplicate delivered email — is **moot while this
108
+ cron is off** (see the box above). It becomes live again the moment anyone sets `"active": 1`.
103
109
 
104
110
  It was also the last Prudential cron still calling `file_get_contents()` directly for its PDFs and
105
111
  calling `addAttachment()` unconditionally — so a failed S3 pull threw and killed the batch, and a
@@ -201,6 +207,10 @@ stamp (crash hits the next iteration). With multiple New-type POs, a crash on PO
201
207
  all subsequent POs in the same run.
202
208
 
203
209
  ## Change history
210
+ - 2026-09-11 — **Correction: `transmissions_to_netsuite.php` is `"active": 0`, not `active: 1`.**
211
+ Disabled 2026-07-17 (commit `14570def`) in `cron.worker.sync.json:441`, so the "second shipped
212
+ sender" and the suspected duplicate *delivered* email are dormant, not live. Found while
213
+ auditing that cron as the only reader of `ItemClasses`. (rgirish)
204
214
  - 2026-08-31 — TRUE-81393 / PR #1707 (merged to `_production`): the shipped email's
205
215
  `$attachmentText` paragraph was unconditional while each attach was guarded, so a failed S3 pull
206
216
  produced copy promising guides that were not attached — and it said "guides" even for a single
@@ -15,7 +15,7 @@ project: _Underscore
15
15
  client: prudential
16
16
  type: profile
17
17
  status: active
18
- updated: 2026-09-10
18
+ updated: 2026-09-11
19
19
  owners: ["jcardinal", "rgirish", "bala", "mhammontree"]
20
20
  files: []
21
21
  related:
@@ -31,6 +31,7 @@ related:
31
31
  where each code is logged, and how to tell "we refused it" from "they never sent it".
32
32
  - features/supply-orders-csv-export.md
33
33
  - features/order-email-pdf-attachments.md
34
+ - features/itemclasses-device-tier-data.md
34
35
  ---
35
36
 
36
37
  ## Summary
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.797",
3
+ "version": "1.0.799",
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",