toga-ai 1.0.618 → 1.0.620

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,8 +6,8 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-21
10
- owners: ["dfranks", "jcardinal"]
9
+ updated: 2026-08-19
10
+ owners: ["dfranks", "jcardinal", "kyalamarthi"]
11
11
  files:
12
12
  - library/app/api/netsuite/rest.php
13
13
  - library/ssl/netsuite_ec_key.pem
@@ -99,6 +99,17 @@ In production (worker/EB) `send()` is fine — the Logs DB is reachable there.
99
99
  `'YYYY-MM'` for bucketing, `NVL(...)`, and `BUILTIN.DF(field)` for a reference field's display
100
100
  value (e.g. `BUILTIN.DF(entity)` → customer name, `BUILTIN.DF(status)` → "Pending Fulfillment") —
101
101
  a cheap alternative to per-id GETs for display strings.
102
+ - **⚠ Column ALIASES come back LOWER-CASED.** `SELECT … AS accountTypeLabel` returns the response key
103
+ **`accounttypelabel`**. Reading it back in camelCase (`$row->accountTypeLabel`) yields **`NULL` with no
104
+ error, no warning and no exception** — the query succeeded, the job reports success, and every value is
105
+ quietly empty. Confirmed empirically on this account (`BUILTIN.DF(sd.recordtype) AS recordTypeName` →
106
+ `recordtypename`). **Write all-lowercase aliases, or read the lower-cased key.** This has caused two
107
+ separate defects in the 2.0 customer sync, one of which is still live.
108
+ - **⚠ A List/Record custom field selected RAW returns the list member's INTERNAL ID, not its label.**
109
+ Nothing errors — you simply store `3` where the report expects "Strategic Growth". Wrap it:
110
+ `BUILTIN.DF(custentity_<x>)`. This applies to **every** List/Record custom field, and it is not
111
+ discoverable from the column name, so **probe the field before trusting a raw select** (issue the
112
+ `BUILTIN.DF` form once and compare). See the `customer` table section for a worked example.
102
113
  - **`ORDER BY` inside a `GROUP BY` query is rejected** with `Invalid or unsupported search`. Sort in
103
114
  PHP after fetching all pages.
104
115
  - **Correlated subqueries are supported** (used in `listSales()` for `orderLine` — see the
@@ -261,6 +272,15 @@ work; the pk is `nkey`). Columns: `addr1`, `addr2`, `addressee`, `attention`, `c
261
272
  4968, script id `custentity10`); SOAP `customFieldList` CF internalId 4968 maps to this.
262
273
  - `entityid` and `companyname` are split in SuiteQL; SOAP concatenated them as
263
274
  `"<entityid> <companyname>"`. When they're equal, return the value once (don't duplicate).
275
+ `App_Api_Netsuite_Rest::listCustomers()` is the authoritative implementation of that rule — any other
276
+ writer of a customer *name* column must mirror it exactly, or the two writers will fight
277
+ (`"6096"` vs `"6096 ODP Veyer (B2B)"`). Verified 2026-08-19 on four production-sourced names.
278
+ - **`BUILTIN.DF(custentity_account_type)`** = display label of the **Account Type** entity field
279
+ (NetSuite internal id **10719**). It is a **List/Record** field, so a raw `SELECT
280
+ custentity_account_type` gives you the member's internal id. Live map (verified 2026-08-19):
281
+ **1 = Strategic Emerging · 2 = Not Strategic · 3 = Strategic Growth · 4 = Strategic Maintain ·
282
+ 5 = Strategic Draining.** Sparsely populated — **248 of 9,183** customers (2.7%) have it set, so a
283
+ mostly-empty result is correct, not a broken query.
264
284
 
265
285
  ## `systemnote` table — field-change forensics
266
286
 
@@ -283,7 +303,8 @@ field changed, **including sublist LINE edits that fire no User Event and theref
283
303
  |---------------|-----------|-------------|
284
304
  | 3149 | `CUSTBODY_END_CUSTOMER` | End Customer (on transaction) |
285
305
  | 7097 | `CUSTBODY_STOCKING_ORDER` | Stocking Order flag |
286
- | 4968 | `custentity10` | Consolidated Customer (on customer record) |
306
+ | 4968 | `custentity10` | Consolidated Customer (on customer record) — List/Record; needs `BUILTIN.DF` |
307
+ | 10719 | `custentity_account_type` | Account Type (on customer record) — List/Record; needs `BUILTIN.DF` |
287
308
  | 6840 | `custbody_forecast_category` | Forecast Category (on opportunity) |
288
309
  | 6841 | `custbody_sales_stage` | Sales Stage (on opportunity) |
289
310
  | 3942 | `custbody_ctc_account` (`CF_ACCOUNT`) | Account |
@@ -371,6 +392,15 @@ numbers. Budget hours for multi-year runs and launch them under `nohup`/`tmux`.
371
392
 
372
393
  ## Change history
373
394
 
395
+ - 2026-08-19 — Added two SuiteQL mechanics that fail **silently**, both found porting the customer
396
+ account-type sync (TRUE-80206): **column aliases come back lower-cased** (`AS accountTypeLabel` →
397
+ `accounttypelabel`, so a camelCase read is `NULL` with no error — it made a backfill report success while
398
+ writing nothing), and **a List/Record custom field selected raw returns the member's internal id, not its
399
+ label** — always `BUILTIN.DF()`. Documented `custentity_account_type` (internal id 10719, 1–5 label map,
400
+ populated on only 2.7% of customers) in the `customer` section and the custom-field table, and noted that
401
+ `listCustomers()`'s entityid/companyname concatenation is the rule any other writer of a customer name
402
+ must mirror byte-for-byte. Also recorded (elsewhere) that `scriptdeployment` is queryable via SuiteQL but
403
+ absent from the REST record catalog. (kyalamarthi)
374
404
  - 2026-07-21 — **Recorded the "one PHP 7.4+ token = total outage" blast-radius lesson** on the PHP
375
405
  7.2 compat gotcha (folded in from a retired project-local CLAUDE.md). On 2026-06-10 a single
376
406
  `static fn(...)` in `rest.php` parse-errored `App_Api_Netsuite_Rest` on class load and crashed ALL
@@ -6,8 +6,8 @@ project: Database Changes
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-08-17
10
- owners: ["mhammontree", "bala"]
9
+ updated: 2026-08-19
10
+ owners: ["mhammontree", "bala", "kyalamarthi"]
11
11
  files:
12
12
  - dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql
13
13
  - dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql
@@ -233,8 +233,37 @@ blocked by the id-divergence hazard in item 2 above, which is why both go to jca
233
233
  - `item-fulfillments.returnAddressId` — the **column exists** but there is **no Core RecordField**.
234
234
  Only needed for the return-label flow.
235
235
 
236
+ ## ⚠ It is not only metadata — the `Forecast/` DDL fan-out is stale too (dev-sandbox, 2026-08-19)
237
+
238
+ The rest of this workflow is about **metadata** rows drifting. The same "someone has to remember to run
239
+ it there" gap applies to plain **DDL**, and on dev-sandbox the `Forecast/` folder looks like it has not
240
+ been applied since roughly **February 2026**:
241
+
242
+ | Column | Migration date | dev-sandbox |
243
+ |---|---|---|
244
+ | `Employees.departmentId` | 2026-02 | present |
245
+ | `Customers.accountType` | 2026-07-14 | **missing** |
246
+ | `Sales.amountDue` | 2026-06-09 | **missing** |
247
+ | `Opportunities.clickupTaskId` | 2026-07-20 | **missing** |
248
+
249
+ All three missing columns are merged in `dbchanges2` `_main`. Note the shape differs from the `Client/`
250
+ fan-out gap documented above: this is not a few skipped dates, it is a **continuous ~6-month tail**.
251
+
252
+ **Consequence: dev-sandbox cannot be used to test recent Forecast work.** A dry run there dies on the
253
+ missing column, which reads like an application bug and burns an afternoon. Until the `Forecast/` fan-out
254
+ is caught up, test against a **local Forecast fixture** instead — see
255
+ [running worker2 locally](../../worker2/workflows/running-worker2-locally.md). Before assuming a
256
+ `Forecast` column exists in any non-prod environment, check the environment, not the repo.
257
+
236
258
  ## Change history
237
259
 
260
+ - 2026-08-19 — Recorded that the drift is **not only metadata**: dev-sandbox's `Forecast/` **DDL** fan-out
261
+ appears unapplied since ~Feb 2026 — it has `Employees.departmentId` (2026-02) but is missing
262
+ `Sales.amountDue` (2026-06-09), `Customers.accountType` (2026-07-14) and `Opportunities.clickupTaskId`
263
+ (2026-07-20), all merged in `_main`. Unlike the `Client/` gap this is a continuous ~6-month tail, not a
264
+ few skipped dates. Practical effect: **dev-sandbox cannot test recent Forecast work** — a dry run fails
265
+ on the missing column and looks like an application bug; use a local Forecast fixture instead. Surfaced
266
+ while testing the customer account-type sync (TRUE-80206). (kyalamarthi)
238
267
  - 2026-08-17 (later pass) — Extended the fan-out gap: it **also hits `_modules/<module>/`**, not just
239
268
  `Client/`. Beta `Client_Aig` had never run `_modules/netsuite/2026-07-10a - UnitInventoryFields.sql`
240
269
  despite `_modules.txt` listing `netsuite`, so `Units` lacked six `c_` columns prod has → 1054/EV-12
@@ -29,7 +29,8 @@
29
29
  | [NetSuite ↔ ClickUp / TOGA Opportunity Sync (API Message Queue + worker2 webhook)](features/netsuite-opportunity-sync.md) | Outbound sync from NetSuite to TOGA for the record types the Forecast2 importer pulls (opportunities first; sales/items/etc. | worker2/Worker/Netsuite.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Clickup.php, worker2/Worker/Clickup/Opportunity.php, worker2/Controller/Index.php, _underscore/Worker.php, test/@dave/NetSuite/api-message-queue/lib_amq_queue.js, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/NetSuite/api-message-queue/ue_amq_drain.js, test/@dave/NetSuite/api-message-queue/ss_amq_drain.js, test/@dave/NetSuite/api-message-queue/DEPLOY_RUNBOOK.md, test/@dave/clickup/backfill_opportunity_numbers.php, test/@dave/clickup/probe_opportunity_fields.php, test/@dave/probe_clickup_desc_match.php, test/@dave/test_model_load_behavior.php, dbchanges2/Forecast/2026-06-25a - Add unique index on Opportunities netsuiteOpportunityInternalId.sql, _underscore/Model/Forecast/Opportunity.php, test/@dave/approach/TRUE-80044.md, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
30
30
  | [NetSuite → Forecast Open-Orders Sync (salesOrder webhook → OpenOrderItems)](features/netsuite-salesorder-open-orders-sync.md) | Webhook-driven, single-record port of the legacy open-orders importer (TRUE-79142). | worker2/Worker/Netsuite/SalesOrder.php, worker2/Worker/Netsuite.php, worker2/Component/Forecast/Db/Db.php, worker2/Worker/Netsuite/Location.php, test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_amq_invoice_resync_salesorder.js, test/@dave/probe_salesorder_rest_shape.php, test/@dave/probe_open_order_lines.php, test/@dave/check_so_status.php, test/@dave/check_so_history.php, test/@dave/probe_so_rest_lines.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_open_order_gating.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
31
31
  | [Toga → NetSuite Sales-Order Push (multi-client, mapping-driven worker)](features/netsuite-salesorder-outbound-push.md) | The **outbound** half of `worker2/Worker/Netsuite/SalesOrder.php` (everything from the `OUTBOUND PUSH (REST)` banner down) pushes a Toga sales order **into** Ne | worker2/Worker/Netsuite/SalesOrder.php, test/@Bala/tests/netsuite_salesorder_payload_tests.php, worker/crons/toga2/prudential/transmissions_to_netsuite.php |
32
- | [NetSuite Supporting-Record Webhook Importer (the reusable recipe)](features/netsuite-supporting-record-webhook-importer.md) | A single **repeatable recipe** for porting a legacy daily-pull NetSuite *supporting-record* importer (the lookup/dimension tables behind Forecast2 — Employees, | worker2/Worker/Netsuite/Employee.php, worker2/Worker/Netsuite/Account.php, worker2/Worker/Netsuite/Classification.php, worker2/Worker/Netsuite/Customer.php, worker2/Worker/Netsuite/Item.php, worker2/Worker/Netsuite.php, _underscore/Model/Forecast/Employee.php, _underscore/Model/Forecast/Account.php, _underscore/Model/Forecast/Classification.php, _underscore/Component/Forecast/Db/Db.php, test/@dave/test_employee_lifecycle.php, test/@dave/test_account_lifecycle.php, test/@dave/test_classification_lifecycle.php, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, worker/crons/toga2/forecast2/import_supporting_records.php |
32
+ | [NetSuite Supporting-Record Backfill Worker (bulk reconciliation recipe)](features/netsuite-supporting-record-backfill-worker.md) | The **bulk counterpart** to the [supporting-record webhook importer](./netsuite-supporting-record-webhook-importer.md). | worker2/Worker/Netsuite/CustomerAccountTypeBackfill.php, worker2/Worker/Netsuite/ItemFulfillableBackfill.php, worker2/Worker/Netsuite/Customer.php, library/app/model/forecast2/customer.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
33
+ | [NetSuite Supporting-Record Webhook Importer (the reusable recipe)](features/netsuite-supporting-record-webhook-importer.md) | A single **repeatable recipe** for porting a legacy daily-pull NetSuite *supporting-record* importer (the lookup/dimension tables behind Forecast2 — Employees, | worker2/Worker/Netsuite/Employee.php, worker2/Worker/Netsuite/Account.php, worker2/Worker/Netsuite/Classification.php, worker2/Worker/Netsuite/Customer.php, worker2/Worker/Netsuite/Item.php, worker2/Worker/Netsuite.php, _underscore/Model/Forecast/Employee.php, _underscore/Model/Forecast/Account.php, _underscore/Model/Forecast/Classification.php, _underscore/Component/Forecast/Db/Db.php, test/@dave/test_employee_lifecycle.php, test/@dave/test_account_lifecycle.php, test/@dave/test_classification_lifecycle.php, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/NetSuite/api-message-queue/DEPLOY_RUNBOOK.md, worker/crons/toga2/forecast2/import_supporting_records.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker2/Worker/Netsuite/CustomerAccountTypeBackfill.php, library/app/api/netsuite/rest.php, library/app/model/forecast2/customer.php |
33
34
  | [Background Email-Template Worker (_Worker_Notification_EmailTemplate)](features/notification-email-template.md) | `_Worker_Notification_EmailTemplate::Send(...)` dispatches a **stored, client-defined `EmailTemplates` row off-thread** as a background WorkerJob. | worker2/Worker/Notification/EmailTemplate.php, worker2/Worker/Client/True.php, _underscore/Model/Client/EmailTemplate.php |
34
35
  | [DB-Driven Notification (Internal) Email](features/notification-email.md) | Internal/notification emails (merge-conflict alerts, ops notices — anything system-generated, not client-facing transactional mail) are sent through one worker | worker2/Worker/Notification/Email.php, _underscore/Model/Client/EmailTemplate.php, dbchanges2/Client/2026-06-23a - EmailTemplateWrapper.sql, dbchanges2/Client_True/2026-06-23a - EmailTemplateWrapper.sql |
35
36
  | [NYCHH Asset-Tag Backfill (worker2)](features/nychh-asset-tag-backfill.md) | Keeps NYC Health & Hospitals (`Client_Nychh`) unit asset tags and MAC addresses synced from NetSuite. | worker2/Worker/Client/Nychh.php, worker2/Worker/Client/Nychh/AssetTagBackfill.php, worker/crons/toga2/netsuite/verify_fulfillment_asset_tag_sync_nychh.php, worker/crons/toga2/netsuite/backfill_all_asset_tags_from_netsuite_nychh.php |
@@ -46,4 +47,5 @@
46
47
  | [VAPI Webhook Handler (worker2 — AI-BDR end-of-call processing)](features/vapi-webhook-handler.md) | `_Worker_Vapi` ([worker2/Worker/Vapi.php](worker2/Worker/Vapi.php)) is the **PHP side of the AI-BDR call loop** — the webhook that receives VAPI's end-of-call r | worker2/Worker/Vapi.php, worker2/Worker/Ai/Bdr/Vapi.php, worker2/Controller/Index.php |
47
48
  | [WJE Freshservice Sync (worker2)](features/wje-freshservice-sync.md) | WJE ("WJE IT", helpdesk `wje.freshservice.com`) is a **Freshservice**-based help-desk client whose tickets, contacts, assets, groups, categories, and canned res | worker2/Worker/Wje.php, _underscore/Component/Api/Wje/Wje.php, _underscore/Model/Wje/Ticket.php, _underscore/Model/Wje/TicketNote.php, _underscore/Model/Wje/Contact.php, _underscore/Model/Wje/Unit.php, _underscore/Model/Wje/TicketTeam.php, _underscore/Model/Wje/TicketCategory.php, _underscore/Model/Wje/AssetType.php, _underscore/Model/Wje/PredefinedReply.php, library/app/api/wje.php, worker/crons/toga2/wje/import_supporting_records.php, worker/crons/toga2/wje/sync_togasupply_wje.php, worker/crons/notifications/reports/wje/wje_common.php, library/app/systemmonitor/wje.php, dbchanges2/Client_Wje/2024-10-04 - WjeOnboarding.sql |
48
49
  | [PHP Runtime Upgrade on Elastic Beanstalk (worker2 8.3 → 8.5 + PhpSpreadsheet 1.x → 3.x)](workflows/php-runtime-upgrade-dependency-audit.md) | The procedure used to move worker2 from **PHP 8.3 to PHP 8.5** on Elastic Beanstalk, and the dependency work that had to land first. | worker2/composer.json, worker2/composer.lock, worker2/Worker/Team/Sprint.php, worker2/Worker/Client/TowFoundation/ProcessReceipts.php, worker2/Worker/Forecast/Import.php |
50
+ | [Running worker2 locally against real NetSuite (and what EB does instead)](workflows/running-worker2-locally.md) | How to boot **worker2** on a developer machine and run a real worker action against the **live NetSuite** account and a **local** `Forecast` database. | worker2/index.php, worker2/composer.json, worker2/.ebextensions/php_include_underscore.config, worker2/.ebextensions/git.json, worker2/.ebextensions/git.php, worker2/Config/production.ini, _underscore/Component/Api/Netsuite/Netsuite.php, dbchanges2/Logs_Client/2026-04-08_BLANK_CLIENT_LOGS_DATABASE.SQL |
49
51
  | [Ticket → ClickUp Pseudocode Planning (Talos-grounded)](workflows/ticket-to-pseudocode-planning.md) | A repeatable procedure for turning a ClickUp ticket into a reviewed, formatted implementation plan posted back to the ticket's `📝 Pseudocode` custom field. | test/@dave/clickup_md2delta.js |
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-17
9
+ updated: 2026-08-19
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - worker2/Worker/Infrastructure/CloudWatch.php
@@ -28,7 +28,10 @@ OneUptime string-matches — OneUptime cannot compare numbers on a pushed body.
28
28
 
29
29
  The load-bearing design decision this doc records is the **paging (alarm) criteria**: page
30
30
  on EB `Degraded`/`Severe`, but **suppress the common "an occasional HTTP 500 tripped
31
- Degraded" false alarm** unless 5xx errors actually dominate the request mix.
31
+ Degraded/Severe" false alarm** unless 5xx errors actually dominate the request mix (by
32
+ share) or reach a real absolute volume. The 5xx gate applies to **both** `Degraded` and
33
+ `Severe`, and only when EB attributes the problem **solely** to 5xx — a mixed cause always
34
+ pages.
32
35
 
33
36
  **Critical behavior:** the method is **non-fatal and never throws** — it always returns a
34
37
  string. worker2 has **no DLQ and a 3600s SQS visibility timeout**, so any uncaught 500
@@ -53,15 +56,33 @@ itself runs non-fatal.
53
56
  Per environment, `alarm=HIGH` (page) when the EB `HealthStatus` is `Degraded` **or**
54
57
  `Severe`. `Suspended` never pages.
55
58
 
56
- - **`Severe` always pages.**
57
- - **`Degraded` for a non-5xx reason** (latency, instances down, …) **always pages.**
58
- - **`Degraded` attributed to HTTP 5xx errors pages only when 5xx errors dominate** — i.e.
59
- the 5xx share of requests is at/above `HTTP_5XX_ALARM_RATIO` (default 0.80). An occasional
60
- 500 that trips Degraded is noise and must **not** page; a flood is a real problem and
61
- **must** page.
62
- - **Tiny-sample guard:** below `HTTP_5XX_MIN_REQUEST_COUNT` (default 20) requests in the
63
- window, do **not** trust the ratio page rather than let a 2-of-3 blip read as "80%
64
- failing."
59
+ The **5xx gate applies to both `Degraded` and `Severe`.** A non-5xx or mixed-cause
60
+ `Degraded`/`Severe` and any env with no stated cause **always pages**; the gate only
61
+ ever suppresses an env EB attributes **solely** to 5xx.
62
+
63
+ - **Non-5xx or mixed-cause `Degraded`/`Severe`** (latency, instances down, a failed deploy,
64
+ or a 5xx trickle *alongside* a real non-5xx failure) **always pages.**
65
+ - **Solely-5xx `Degraded`/`Severe` pages only on a genuine flood** — either:
66
+ - the **absolute** 5xx count in the window is at/above `HTTP_5XX_ALARM_ABSOLUTE_COUNT`
67
+ (default 20), **or**
68
+ - the **5xx share** of requests is at/above `HTTP_5XX_ALARM_RATIO` (default 0.80).
69
+ - **Zero requests in the window → no page** (nothing observed; also avoids divide-by-zero).
70
+
71
+ **Why the gate now covers `Severe` too:** EB escalates an environment to `Severe` precisely
72
+ when the 5xx share is high, so real 5xx floods reach `Severe` *first* and never touch the
73
+ gated `Degraded` branch. When the gate applied only to `Degraded`, the 80% threshold was
74
+ effectively dead — a real 50%-5xx `Severe` event paged despite the setting. Gating both
75
+ tiers makes the threshold actually govern.
76
+
77
+ **Why "solely" 5xx, not "any" 5xx cause:** a mixed-cause env (a 5xx trickle plus a real
78
+ non-5xx failure such as a failed deploy) must not be silenced by a sub-threshold ratio. The
79
+ ratio gate only applies when **every** stated cause references 5xx (and there is at least
80
+ one); anything else pages.
81
+
82
+ **Why an absolute floor in addition to the ratio:** the ratio alone is magnitude-blind —
83
+ 60k failing out of 100k reads as 60% and would be suppressed, though it is plainly a flood.
84
+ `HTTP_5XX_ALARM_ABSOLUTE_COUNT` pages such an env regardless of share; tune it to the busiest
85
+ monitored environment's request volume.
65
86
 
66
87
  ### Fail-safe: a blind read pages, never reads healthy
67
88
 
@@ -76,18 +97,23 @@ signal — same alarm-vs-probe split as the multi-client monitors in
76
97
  | Constant | Default | Meaning |
77
98
  |---|---|---|
78
99
  | `HEALTH_ALARM_STATUSES` | `['Degraded','Severe']` | HealthStatus values that page |
79
- | `HTTP_5XX_ALARM_RATIO` | `0.80` | 5xx share (0.0–1.0) at/above which a 5xx-driven `Degraded` pages |
80
- | `HTTP_5XX_MIN_REQUEST_COUNT` | `20` | Below this many requests in the window, page rather than trust the ratio |
100
+ | `HTTP_5XX_ALARM_RATIO` | `0.80` | 5xx share (0.0–1.0) at/above which a solely-5xx `Degraded`/`Severe` pages |
101
+ | `HTTP_5XX_ALARM_ABSOLUTE_COUNT` | `20` | Absolute 5xx count in the window at/above which a solely-5xx `Degraded`/`Severe` pages regardless of share |
81
102
  | `HTTP_5XX_CAUSE_MARKERS` | `['5xx','http 5']` | Case-insensitive substrings identifying a 5xx-attributed EB `Cause` |
82
103
 
83
104
  ### Implementation shape
84
105
 
85
106
  - `shouldPageForHealth(healthStatus, causes, applicationMetrics): [bool, ?float]` — returns
86
107
  the page decision and the observed 5xx ratio.
87
- - `causesAttributeTo5xx(causes): bool` — substring-matches `HTTP_5XX_CAUSE_MARKERS` against
88
- the EB `Causes` strings (case-insensitive).
108
+ - `causesAttributeSolelyTo5xx(causes): bool` — true only when **every** EB `Cause` references
109
+ 5xx **and there is at least one** cause. Replaces the former `causesAttributeTo5xx()`, which
110
+ returned true if *any* cause mentioned 5xx and so let a mixed-cause env be gated.
111
+ - `causeIsFivexx(cause): bool` — helper that substring-matches `HTTP_5XX_CAUSE_MARKERS`
112
+ against a single EB `Cause` string (case-insensitive); `causesAttributeSolelyTo5xx` requires
113
+ it to hold for all causes.
89
114
  - The ratio is computed as `StatusCodes.Status5xx / RequestCount` from `ApplicationMetrics`
90
- (raw counts — see below), **not** by parsing the percentage out of the `Causes` text.
115
+ (raw counts — see below), **not** by parsing the percentage out of the `Causes` text. The
116
+ absolute-count floor reads `StatusCodes.Status5xx` directly.
91
117
  - `describeEnvironmentHealth` is called with
92
118
  `AttributeNames = ['HealthStatus','Status','Color','Causes','ApplicationMetrics']`.
93
119
  - Per-environment report + OneUptime payload now include the per-env `alarm` token and the
@@ -147,9 +173,10 @@ iteration:** a deploy that fails fast *without ever degrading health* is not cau
147
173
  **whole run** via the outer backstop instead of paging just that region's envs as blind.
148
174
  Candidate follow-up now that blind reads page.
149
175
  - **No first-party test harness exists in worker2** (all tests are vendor/).
150
- `shouldPageForHealth()` and `causesAttributeTo5xx()` are pure and ideal to unit-test
151
- (ratio at exactly 0.80; count 19 vs 20; `Severe` over a 5xx cause; empty causes; null
152
- metrics) — deferred pending a harness.
176
+ `shouldPageForHealth()` and `causesAttributeSolelyTo5xx()` are pure and ideal to unit-test
177
+ (ratio at exactly 0.80; absolute count 19 vs 20; `Severe` over a solely-5xx cause; a
178
+ mixed 5xx + non-5xx cause; empty causes; null metrics; zero requests) — deferred pending a
179
+ harness.
153
180
 
154
181
  ## Gotchas / known issues
155
182
 
@@ -164,8 +191,28 @@ iteration:** a deploy that fails fast *without ever degrading health* is not cau
164
191
  — never parse the percentage out of `Causes`.**
165
192
  - **`oneuptimeUrl` is a push credential** — it arrives as a cron parameter; never log it or
166
193
  record its value in a doc.
194
+ - **Near-idle ratio edge (residual, accepted).** With the tiny-sample guard removed, a
195
+ near-idle env whose window holds a tiny all-error sample (e.g. its only request being a
196
+ 500 = 100%) still trips the ratio and pages. Optional future guard: require a minimum 5xx
197
+ count before the ratio applies (the absolute floor gates high volume, not this low-volume
198
+ edge).
167
199
 
168
200
  ## Change history
201
+ - 2026-08-19 — Overhauled the 5xx alarm gating in `shouldPageForHealth()`. The 80% share
202
+ gate now applies to **both** `Degraded` and `Severe` (was `Degraded`-only, which left the
203
+ threshold dead because EB escalates real 5xx floods straight to `Severe` — a 50%-5xx
204
+ `Severe` had been paging despite the 0.80 setting). This Severe-gating trade-off was made
205
+ deliberately with an independent architecture second opinion on record (cto returned
206
+ DISAGREE-WITH-ALTERNATIVE; developer accepted). Narrowed 5xx classification: replaced
207
+ `causesAttributeTo5xx()` (any cause mentions 5xx) with `causesAttributeSolelyTo5xx()` (every
208
+ cause references 5xx, ≥1) + a `causeIsFivexx()` helper, so a mixed cause (5xx trickle beside
209
+ a real non-5xx failure) always pages — a security-review gap the Severe change would have
210
+ widened. Added `HTTP_5XX_ALARM_ABSOLUTE_COUNT` (20): a solely-5xx env pages regardless of
211
+ share once the absolute 5xx count reaches the floor (closes the ratio's magnitude-blindness,
212
+ e.g. 60k of 100k). Removed `HTTP_5XX_MIN_REQUEST_COUNT` (was 20) and its tiny-sample
213
+ page-anyway guard — a small number of 5xx is now judged purely on the ratio; below the
214
+ absolute floor, page only if share ≥ 0.80, and zero requests → no page. Accepted residual:
215
+ a near-idle env with a tiny all-error sample still trips the ratio. (jcardinal)
169
216
  - 2026-08-17 — Created. Documented the `ElasticBeanstalkHealth` alarm/paging criteria (page
170
217
  on `Degraded`/`Severe`; suppress a 5xx-driven `Degraded` unless the 5xx share ≥
171
218
  `HTTP_5XX_ALARM_RATIO` 0.80 with an `HTTP_5XX_MIN_REQUEST_COUNT` 20 tiny-sample guard;
@@ -178,5 +225,3 @@ iteration:** a deploy that fails fast *without ever degrading health* is not cau
178
225
  covers the reads). Recorded the reverted `['Severe']`-only + deployment-failure-detector
179
226
  direction and the accepted residual gap (a deploy that fails without degrading health).
180
227
  (jcardinal)
181
- </content>
182
- </invoke>
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-15
10
- owners: ["dfranks"]
9
+ updated: 2026-08-19
10
+ owners: ["dfranks", "kyalamarthi"]
11
11
  files:
12
12
  - worker2/Worker/Netsuite.php
13
13
  - worker2/Worker/Netsuite/Opportunity.php
@@ -276,6 +276,22 @@ None — platform-wide Forecast sync.
276
276
  enqueuer's `scriptnote` AUDIT log going quiet and the missing opps having zero log entries. When opps go
277
277
  missing, **check the enqueuer deployment's Status AND Audience (All Roles/All Employees = T)**, not just
278
278
  "is it Released." Backfill the gap window with `trueup_opportunities.php`.
279
+ - **⚠ The enqueuer is deployed PER RECORD TYPE, and a record type left at `Testing` / `isdeployed = F`
280
+ enqueues NOTHING — forever, silently.** Same failure family as the empty-audience incident above, and it
281
+ is the default state of a new deployment (runbook §3: "Status Testing — fires only for you → safe"),
282
+ so it is missed by *forgetting* §6's promotion step rather than by doing anything wrong.
283
+ Live case, found 2026-08-19 (TRUE-80206): `customscript_ue_amq_enqueue` for **Customer** (deployment
284
+ internal id **9**, script id `customdeploy9`) sat at `status = TESTING` / `isdeployed = F` while the
285
+ other **15** record types were `RELEASED` / `T`. Consequence: **zero** `Netsuite/Customer/*` jobs had
286
+ ever run in production — the handler, the model and the migration had all been merged since 2026-07-14
287
+ and every one of the 9,206 rows was still NULL. Nothing errored, because nothing ran.
288
+ - **`Testing` fires only for the deploying user**, and `isdeployed = F` stops it entirely — so a
289
+ developer testing in the UI sees the sync "work" while production gets nothing.
290
+ - **Diagnose it with SuiteQL:** the `scriptdeployment` table exposes `status` / `isdeployed` per
291
+ deployment, and querying all record types side by side is what made the one-of-sixteen outlier
292
+ obvious. **`scriptdeployment` is NOT in the NetSuite REST record catalog**, so you can *read* it via
293
+ SuiteQL but you **cannot change it via the API** — promotion is a UI (or SDF) action only.
294
+ - Whenever a record type "isn't syncing", check this **before** reading any PHP.
279
295
  - **NetSuite SuiteQL renders timestamps in TWO different zones — do not compare them naïvely.** Stored
280
296
  datetime fields (a custom record's `created`, transaction dates, `systemnote.date`) and the NetSuite UI
281
297
  render in the **account/user TZ (Eastern here)**, but **`SYSDATE` and `scriptnote.date` render in
@@ -486,6 +502,14 @@ deprecated** for production opportunity code.
486
502
  blocked on the Aaron stakeholder decision noted above.
487
503
 
488
504
  ## Change history
505
+ - 2026-08-19 — Recorded a second, worse instance of the "`Released` ≠ active" enqueuer-deployment failure
506
+ family: the AMQ enqueuer is deployed **per record type**, and **Customer** (`customdeploy9`, deployment
507
+ internal id 9) was still at `status = TESTING` / `isdeployed = F` while the other 15 types were
508
+ `RELEASED`/`T` — so **no `Netsuite/Customer/*` job had ever run in production** since the code merged on
509
+ 2026-07-14 (root cause of TRUE-80206; resolved by setting Released + Deployed in the NetSuite UI, after
510
+ which the first-ever job succeeded in 0.9 s). Added the diagnostic: the `scriptdeployment` table is
511
+ readable via **SuiteQL** (comparing all record types side by side is what exposed the outlier) but is
512
+ **absent from the REST record catalog**, so the promotion can only be done in the UI/SDF. (kyalamarthi)
489
513
  - 2026-07-15 — **Decided the atomic ClickUp-task dedup approach (TRUE-80044) — planning/rework, no code
490
514
  shipped.** The non-atomic find-then-create lets concurrent NS opportunity webhooks create duplicate
491
515
  ClickUp tasks. Decided fix: a race-safe **conditional-UPDATE claim** on a new `clickupTaskId` column of
@@ -0,0 +1,126 @@
1
+ ---
2
+ title: NetSuite Supporting-Record Backfill Worker (bulk reconciliation recipe)
3
+ framework: "2.0"
4
+ repo: worker2
5
+ project: Worker
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-19
10
+ owners: ["kyalamarthi"]
11
+ files:
12
+ - worker2/Worker/Netsuite/CustomerAccountTypeBackfill.php
13
+ - worker2/Worker/Netsuite/ItemFulfillableBackfill.php
14
+ - worker2/Worker/Netsuite/Customer.php
15
+ - library/app/model/forecast2/customer.php
16
+ - worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php
17
+ related:
18
+ - ./netsuite-supporting-record-webhook-importer.md
19
+ - ../workflows/running-worker2-locally.md
20
+ - ../../../../1.0/apps/library/features/netsuite-suiteql-api-reference.md
21
+ ---
22
+
23
+ ## Summary
24
+
25
+ The **bulk counterpart** to the [supporting-record webhook importer](./netsuite-supporting-record-webhook-importer.md).
26
+ The webhook keeps a `Forecast` column current *going forward*, one record at a time. It cannot fix
27
+ history, and it never fires for a NetSuite **bulk** change. A backfill worker re-derives one column
28
+ for **every** row from NetSuite in batches, and is the reconciliation path after any mass edit.
29
+
30
+ Two exist and they share one recipe: `Netsuite/ItemFulfillableBackfill/…` (the original) and
31
+ `Netsuite/CustomerAccountTypeBackfill/Backfill` (TRUE-80206). Treat the recipe as fill-in-the-blanks —
32
+ the interesting decisions are already made.
33
+
34
+ ## Key files / entry points
35
+
36
+ - **`worker2/Worker/Netsuite/CustomerAccountTypeBackfill.php`** —
37
+ `abstract class _Worker_Netsuite_CustomerAccountTypeBackfill`.
38
+ Action **`Netsuite/CustomerAccountTypeBackfill/Backfill`**, parameters:
39
+ - `dryRun` — **defaults to `true`.** A caller must opt *in* to writing (`dryRun=false`).
40
+ - `netsuiteInternalId` — optional; restricts the run to a single customer (the fast way to
41
+ verify one record end-to-end without a full pass).
42
+ - **`worker2/Worker/Netsuite/ItemFulfillableBackfill.php`** — the file to clone for the next one.
43
+
44
+ ## How it works (the recipe)
45
+
46
+ 1. **Page the local side.** Read the `Forecast` rows to reconcile in pages of **1,000**.
47
+ 2. **Ask NetSuite in batches.** One SuiteQL query per batch of **≤ 500** ids
48
+ (`… WHERE id IN (<ids>)`). Ids are local PKs / NetSuite internal ids cast with `(int)` — the
49
+ same cast-only injection control the webhook handlers use (there is no placeholder binding on
50
+ the SuiteQL REST endpoint).
51
+ 3. **Write back in batches of ≤ 500**, skipping rows already correct.
52
+ 4. **`dryRun` defaults to `true`** so an accidental invocation is a report, not a mutation.
53
+ 5. **Return a countable summary on EVERY path** (scanned / found in NetSuite / already correct /
54
+ updated / skipped) — worker2 actions must return a result on both the success and the
55
+ nothing-to-do path, or the job looks hung.
56
+ 6. **Idempotent by construction.** An immediate re-run must report *N already correct / 0 updated*.
57
+ That re-run is the acceptance test.
58
+
59
+ ### The dry run doubles as the field-type probe
60
+
61
+ `CustomerAccountTypeBackfill::detectAccountTypeExpression()` issues **one** probe query using
62
+ `BUILTIN.DF(<field>)` and falls back to the raw column if that errors, then prints the expression it
63
+ chose **and the values it would store**. So the first dry run answers "is this custom field a
64
+ List/Record or a scalar?" before a single row is written. Copy this when backfilling any custom
65
+ field whose type you have not personally confirmed — guessing wrong stores internal ids
66
+ (see the [List/Record trap](./netsuite-supporting-record-webhook-importer.md)).
67
+
68
+ ## Why this worker exists — do not treat it as a throwaway script
69
+
70
+ `Forecast.Customers.accountType` has **no nightly backstop**. `App_Model_Forecast2_Customer`
71
+ (`library/app/model/forecast2/customer.php`) does not declare the column, so the 2 AM
72
+ "Forecast 2.0 — Import supporting records" cron can neither write it nor clobber it. That omission
73
+ is deliberate (the model-omits-externally-managed-columns rule), but it means **the webhook is the
74
+ only ongoing writer** — and:
75
+
76
+ - **Bulk NetSuite changes fire no User Event.** A Mass Update, or a CSV import without
77
+ *"Run Server SuiteScript and Trigger Workflows"*, updates the record without enqueuing anything.
78
+ The webhook never sees it.
79
+ - **Therefore: after any bulk reclassification in NetSuite, re-run this backfill.** It is the only
80
+ thing that will bring `Forecast` back into agreement.
81
+
82
+ ## Production results (2026-08-19)
83
+
84
+ - **248 of 9,183** NetSuite customers have an Account Type set — **2.7%**. The other 97% are legitimately
85
+ blank; do not read "blank" as "broken".
86
+ - Full backfill: **248 updated in 8.5 s**. Immediate re-run: **248 already correct / 0 updated**.
87
+
88
+ ## Gotchas / known issues
89
+
90
+ - **⚠ The 1.0 nightly cron is effectively INSERT-ONLY for customers — latent bug, NOT fixed.**
91
+ `worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php:256-259` guards the update with
92
+ `name != … && consolidatedCustomerId != …` where it plainly means `||`, so an existing row is only
93
+ rewritten when **both** values changed. Renames therefore never propagate from NetSuite. Do not
94
+ assume the nightly cron will heal a stale `Forecast.Customers` row — it will not.
95
+ Evidence (prod, 2026-08-19): Forecast holds **9,206** customers vs **9,183** in NetSuite, i.e. 23
96
+ stale rows that were never pruned either.
97
+ - **`dryRun` is `true` by default** — a run that "did nothing" almost certainly just needed
98
+ `dryRun=false`. Read the returned summary before concluding the data was already correct.
99
+ - **SuiteQL lower-cases every column alias**, so a camelCase alias read back as camelCase is silently
100
+ `NULL` — this broke the first version of this backfill (every customer misreported as "blank in
101
+ NetSuite" while the job reported success). See the
102
+ [importer gotchas](./netsuite-supporting-record-webhook-importer.md).
103
+ - Do not drive a local test of these through `_Worker::runTask` — the dev config's worker queue URL
104
+ points at production SQS. Call the method in-process; see
105
+ [running worker2 locally](../workflows/running-worker2-locally.md).
106
+
107
+ ## Change history
108
+
109
+ - 2026-08-19 — Created after building `_Worker_Netsuite_CustomerAccountTypeBackfill` (TRUE-80206) on
110
+ the `ItemFulfillableBackfill` pattern: recorded the batching recipe (page 1,000 / SuiteQL `IN` 500 /
111
+ UPDATE 500), the `dryRun`-defaults-to-true and countable-summary-on-every-path contract, and the
112
+ **dry-run-as-field-type-probe** trick (`detectAccountTypeExpression()` probes `BUILTIN.DF` once and
113
+ falls back to the raw column). Recorded **why the worker must be kept**: `Forecast.Customers.accountType`
114
+ has no nightly backstop (the 1.0 model does not declare it) and bulk NetSuite edits fire no User Event,
115
+ so this is the only reconciliation path. Also recorded the latent 1.0 cron `&&`-should-be-`||` change
116
+ guard that makes the customer cron insert-only (9,206 Forecast rows vs 9,183 in NetSuite). Prod run:
117
+ 248/9,183 (2.7%) updated in 8.5 s, re-run idempotent. (kyalamarthi)
118
+
119
+ ## Related docs
120
+
121
+ - [NetSuite Supporting-Record Webhook Importer](./netsuite-supporting-record-webhook-importer.md) — the
122
+ real-time single-record sibling; the Customers variant and the List/Record `BUILTIN.DF` rule live there.
123
+ - [Running worker2 locally against real NetSuite](../workflows/running-worker2-locally.md) — how this was
124
+ tested before it touched production.
125
+ - [NetSuite SuiteQL/REST API Reference](../../../../1.0/apps/library/features/netsuite-suiteql-api-reference.md)
126
+ — SuiteQL mechanics, `BUILTIN.DF`, the lower-cased-alias rule, and the custom-field map.
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-10
10
- owners: ["dfranks", "bala"]
9
+ updated: 2026-08-19
10
+ owners: ["dfranks", "bala", "kyalamarthi"]
11
11
  files:
12
12
  - worker2/Worker/Netsuite/Employee.php
13
13
  - worker2/Worker/Netsuite/Account.php
@@ -23,8 +23,14 @@ files:
23
23
  - test/@dave/test_account_lifecycle.php
24
24
  - test/@dave/test_classification_lifecycle.php
25
25
  - test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js
26
+ - test/@dave/NetSuite/api-message-queue/DEPLOY_RUNBOOK.md
26
27
  - worker/crons/toga2/forecast2/import_supporting_records.php
28
+ - worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php
29
+ - worker2/Worker/Netsuite/CustomerAccountTypeBackfill.php
30
+ - library/app/api/netsuite/rest.php
31
+ - library/app/model/forecast2/customer.php
27
32
  related:
33
+ - ./netsuite-supporting-record-backfill-worker.md
28
34
  - ./netsuite-opportunity-sync.md
29
35
  - ./netsuite-salesorder-open-orders-sync.md
30
36
  - ../../_underscore/features/forecast-sale-import.md
@@ -76,15 +82,24 @@ recipe and its two recurring variants so a new one is a fill-in-the-blanks job,
76
82
  *trivially* on merge — keep every helper method).
77
83
  5. **Enqueuer:** add `'<record>':'<record>'` to `RECORD_TYPE_MAP` in
78
84
  `test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js` (account/customer/employee/
79
- classification entries already exist).
85
+ classification entries already exist). **Then promote that record type's `customscript_ue_amq_enqueue`
86
+ deployment to `Status = Released` AND `Deployed = T` in NetSuite** (runbook §6, "Before production").
87
+ A new deployment defaults to `Testing` / `isdeployed = F`, which fires for **nobody** — the code can be
88
+ merged and correct for weeks while zero jobs ever run. This step was missed for Customer and was the
89
+ root cause of TRUE-80206; see [the enqueuer deployment gotchas](./netsuite-opportunity-sync.md).
90
+ **The port is not done until you have seen a real job for the new record type in `Core.WorkerJobs`.**
80
91
  6. **e2e harness** `test/@dave/test_<record>_lifecycle.php`: clone an existing one. It drives NetSuite
81
92
  REST (check/create/get/update/delete) + the in-process worker verbs (post/put/verb-delete) + a local
82
93
  dump of the resulting `Forecast` rows. Boots via chdir `worker2` + `require index.php`.
83
94
  7. **Cron:** leave the legacy `SHOULD_SYNC_<X>` daily pull **ON** as a backstop (and, for self-ref
84
95
  records, the parent/supervisor backfill). It shares `import_supporting_records.php` with the other
85
96
  sections, so **never disable the wrapper.**
97
+ ⚠ **But do not assume the backstop covers your new column** — see *the backstop is narrower than it
98
+ looks* under Gotchas. A column the 1.0 model does not declare has **no** nightly writer at all, and
99
+ for Customers the cron is effectively insert-only. When there is no backstop, ship a
100
+ [backfill worker](./netsuite-supporting-record-backfill-worker.md) with the feature.
86
101
 
87
- ## Two recurring variants
102
+ ## Recurring variants
88
103
 
89
104
  - **Self-referential parent/supervisor FK** (`Employees.supervisorEmployeeId`,
90
105
  `Classifications.parentClassificationId`) = **RESOLVE-OR-NULL, never recursive upsert.** Resolve the
@@ -98,9 +113,38 @@ recipe and its two recurring variants so a new one is a fill-in-the-blanks job,
98
113
  an unmapped value passes through raw. **Keep the const in sync with the shim.**
99
114
  - **Name from a hierarchical NetSuite `name`/`fullname`** = the **leaf segment**
100
115
  (`explode(' : ')` + `array_pop`), matching the SOAP/cron/Item resolver.
116
+ - **List/Record custom field → `BUILTIN.DF()` is MANDATORY** (Customers `accountType`). Selecting a
117
+ List/Record custom field **raw** returns the list member's **internal id**, not its label, and nothing
118
+ errors — you just store `3`. Select `BUILTIN.DF(<field>)` instead. Concretely:
119
+ `custentity_account_type` (NetSuite Entity Field "Account Type", internal id **10719**) maps
120
+ **1 = Strategic Emerging · 2 = Not Strategic · 3 = Strategic Growth · 4 = Strategic Maintain ·
121
+ 5 = Strategic Draining**. Power BI reads `Forecast.Customers.accountType` directly, so a raw id
122
+ renders as `3` on the report. Only **248 of 9,183** customers (2.7%) have the field set at all —
123
+ mostly-blank is correct, not a symptom. If you are unsure of a custom field's type, let the
124
+ [backfill worker's dry run probe it](./netsuite-supporting-record-backfill-worker.md).
125
+ - **A column with TWO writers must agree byte-for-byte on format** (Customers `name`). SOAP returned a
126
+ single `entityId` of the form `"<entityid> <companyname>"`; SuiteQL splits those into two columns, so
127
+ the obvious `SELECT entityid` stores `"6096"` where the legacy 1.0 daily cron stores
128
+ `"6096 ODP Veyer (B2B)"`. Both write `Forecast.Customers.name`, so shipping the bare form would have
129
+ silently truncated the ~6 customer names a day the webhook touches. `Customer.php` therefore has a
130
+ private **`buildCustomerName()`** that mirrors `App_Api_Netsuite_Rest::listCustomers()`
131
+ (`library/app/api/netsuite/rest.php`) **including its "entityid identical to companyname → emit it
132
+ once" rule**. Verified byte-identical against four production-sourced names, one with an apostrophe.
133
+ **Before porting any record whose legacy cron still runs, diff the two writers' output on real rows.**
101
134
 
102
135
  ## Gotchas / known issues
103
136
 
137
+ - **⚠ SuiteQL LOWER-CASES every column alias — reading it back in camelCase silently yields `NULL`.**
138
+ `SELECT … AS accountTypeLabel` returns the key **`accounttypelabel`**; `$row->accountTypeLabel` is then
139
+ `NULL` with no error, no warning and no exception. Confirmed empirically on this account
140
+ (`BUILTIN.DF(sd.recordtype) AS recordTypeName` came back as `recordtypename`). **Use all-lowercase
141
+ aliases, or read the lower-cased key.** This has now caused two separate bugs:
142
+ - it broke the first cut of the account-type backfill — every customer was misreported as "blank in
143
+ NetSuite" **while the job reported success**; only testing caught it;
144
+ - **still open, not fixed:** `worker2/Worker/Netsuite/Customer.php` (~line 147) selects
145
+ `BUILTIN.DF(custentity10) AS consolidatedName` and reads `$record->consolidatedName`, so
146
+ **consolidated-customer names are never set or refreshed** by the webhook. Fix it the next time that
147
+ handler is opened.
104
148
  - **SuiteQL over the REST client has NO server-side placeholder binding.** `_Component_Api_Netsuite::send`
105
149
  passes the query string as-is, so the **`(int)` cast on an already-int id is the injection control** —
106
150
  the same codebase-wide idiom used by `lookupId()` and the Item/Opportunity/Employee handlers. A
@@ -114,14 +158,44 @@ recipe and its two recurring variants so a new one is a fill-in-the-blanks job,
114
158
  no migration needed.
115
159
  - **`save()` does not diff** — see recipe step 2: the change-detect guard before `save()` is what makes
116
160
  a no-change webhook a true no-op.
117
- - **⚠ Unrelated observation, production, 2026-08-10: three of these job types are at 0% success and
118
- nobody is watching.** `Core.WorkerJobs` in prod shows `Netsuite/InventoryItem/post` (266 jobs),
119
- `Netsuite/InventoryItem/put` (1,014) and `Netsuite/ItemGroup/post` (5) with **zero successes**, and
120
- they are still firing daily. Recorded here as a flag for whoever owns these importers — it was not
121
- investigated (surfaced incidentally while auditing WorkerJobs for the Elite sales-order push).
161
+ - **⚠ The backstop is narrower than it looks some columns have NO nightly writer at all.**
162
+ Recipe step 7 says leave the legacy cron on as a backstop, and that is right, but the backstop only
163
+ covers columns the **1.0** model declares. `App_Model_Forecast2_Customer`
164
+ (`library/app/model/forecast2/customer.php`) does not declare `accountType`, so the 2 AM
165
+ "Forecast 2.0 Import supporting records" cron can neither write **nor clobber** it — the webhook is
166
+ the **only** ongoing writer. Worse, for Customers the cron is effectively **insert-only**: its change
167
+ guard (`common_import_sales_from_netsuite.php:256-259`) uses `name != … && consolidatedCustomerId != …`
168
+ where it means `||`, so an existing row is rewritten only when *both* changed and renames never
169
+ propagate (prod: 9,206 Forecast customers vs 9,183 in NetSuite). **Latent bug, not fixed.** And because
170
+ a NetSuite **Mass Update / CSV import without "Run Server SuiteScript"** fires no User Event, a bulk
171
+ reclassification reaches neither writer. Ship a
172
+ [backfill worker](./netsuite-supporting-record-backfill-worker.md) for any column in this position and
173
+ re-run it after every bulk change.
174
+ - **⚠ Unrelated observation, production, updated 2026-08-19: the item job types are still at 0% success,
175
+ the cause is now known, and the volume is growing.** `Core.WorkerJobs` in prod shows
176
+ `Netsuite/InventoryItem/post|put` at **~1,163 failures / 0 successes**, all with
177
+ `watchdog: exceeded maxExecutionTime without completing`, still firing daily; `NonInventoryItem` (55),
178
+ `ItemGroup` (9) and `ServiceItem` (2) are likewise at 0%. The 2026-08-10 snapshot of the same audit was
179
+ `InventoryItem/post` 266 / `InventoryItem/put` 1,014 / `ItemGroup/post` 5. **Still not investigated** —
180
+ recorded as a standing flag for whoever owns the item importers. The failure string points at the
181
+ watchdog/execution-time budget rather than at NetSuite or the data.
122
182
 
123
183
  ## Change history
124
184
 
185
+ - 2026-08-19 — **Customers went live for the first time (TRUE-80206)** and produced four additions.
186
+ (1) **Root cause of the whole ticket:** the `customscript_ue_amq_enqueue` deployment for Customer
187
+ (deployment internal id 9, `customdeploy9`) sat at `Status = TESTING` / `isdeployed = F` while all 15
188
+ other record types were RELEASED/T — so **zero** `Netsuite/Customer/*` jobs had ever run in production
189
+ and all 9,206 rows were NULL, despite code, model and migration being merged since 2026-07-14. Recipe
190
+ step 5 now carries the promote-the-deployment step and the "not done until you see a real job" rule.
191
+ (2) **List/Record custom fields need `BUILTIN.DF()`** — the raw column stores the internal id;
192
+ documented `custentity_account_type` (id 10719) and its 1–5 label map. (3) **SuiteQL lower-cases column
193
+ aliases**, silently returning `NULL` for a camelCase read — it broke the backfill's first cut and is
194
+ **still live** in `Customer.php`'s `consolidatedName`. (4) **Two writers on one column must agree
195
+ byte-for-byte** — added `buildCustomerName()` mirroring `App_Api_Netsuite_Rest::listCustomers()` so the
196
+ webhook stops short of truncating names the 1.0 cron stores concatenated. Also recorded that the legacy
197
+ cron is **not** a universal backstop (no `accountType` in the 1.0 model; `&&`-should-be-`||` change
198
+ guard makes it insert-only) and refreshed the 0%-success item-job flag. (kyalamarthi)
125
199
  - 2026-08-10 — Flagged a **production health observation** (not investigated): prod `Core.WorkerJobs`
126
200
  has `Netsuite/InventoryItem/post` (266), `Netsuite/InventoryItem/put` (1,014) and
127
201
  `Netsuite/ItemGroup/post` (5) at **0% success**, still running daily and apparently unmonitored.
@@ -137,6 +211,8 @@ recipe and its two recurring variants so a new one is a fill-in-the-blanks job,
137
211
 
138
212
  ## Related docs
139
213
 
214
+ - [NetSuite Supporting-Record Backfill Worker](./netsuite-supporting-record-backfill-worker.md) — the bulk
215
+ reconciliation counterpart; ship one whenever the legacy cron does not back your column.
140
216
  - [NetSuite → TOGA Opportunity Sync](./netsuite-opportunity-sync.md) — header+children transaction sibling.
141
217
  - [NetSuite → Forecast Open-Orders Sync](./netsuite-salesorder-open-orders-sync.md).
142
218
  - [Forecast.Sales NetSuite import engine](../../_underscore/features/forecast-sale-import.md) — the
@@ -0,0 +1,123 @@
1
+ ---
2
+ title: Running worker2 locally against real NetSuite (and what EB does instead)
3
+ framework: "2.0"
4
+ repo: worker2
5
+ project: Worker
6
+ client: shared
7
+ type: workflow
8
+ status: active
9
+ updated: 2026-08-19
10
+ owners: ["kyalamarthi"]
11
+ files:
12
+ - worker2/index.php
13
+ - worker2/composer.json
14
+ - worker2/.ebextensions/php_include_underscore.config
15
+ - worker2/.ebextensions/git.json
16
+ - worker2/.ebextensions/git.php
17
+ - worker2/Config/production.ini
18
+ - _underscore/Component/Api/Netsuite/Netsuite.php
19
+ - dbchanges2/Logs_Client/2026-04-08_BLANK_CLIENT_LOGS_DATABASE.SQL
20
+ related:
21
+ - ../features/netsuite-supporting-record-backfill-worker.md
22
+ - ../features/netsuite-supporting-record-webhook-importer.md
23
+ - ../../_underscore/features/cli-script-bootstrap.md
24
+ - ../../dbchanges2/workflows/nonprod-metadata-drift-repair.md
25
+ - ../../api2/features/environment-variable-drives-underscore-branch.md
26
+ ---
27
+
28
+ ## Summary
29
+
30
+ How to boot **worker2** on a developer machine and run a real worker action against the **live
31
+ NetSuite** account and a **local** `Forecast` database. Written from testing the customer
32
+ account-type work (TRUE-80206), where dev-sandbox was unusable (see the schema gotcha below) and
33
+ production was obviously not an option.
34
+
35
+ Five things have to be true, and four of them are supplied by `.ebextensions` on Elastic Beanstalk
36
+ — which is exactly why they are invisible until you try to run the app locally. Each step below
37
+ pairs the local move with the EB mechanism it replaces.
38
+
39
+ > For a *bare CLI script* that wants the 2.0 framework (rather than the worker app itself), use
40
+ > [Running 2.0 code from a bare CLI script](../../_underscore/features/cli-script-bootstrap.md) —
41
+ > its transaction and `ENVIRONMENT` traps apply here too.
42
+
43
+ ## Steps
44
+
45
+ 1. **`composer install` — `vendor/` is gitignored.** A fresh checkout has no dependencies.
46
+ If the machine lacks `ext-zip`, install with `--ignore-platform-req=ext-zip`; the only consumer
47
+ is phpspreadsheet, which a NetSuite/Forecast action never touches.
48
+
49
+ 2. **Make `_underscore` resolvable on the include path.** `worker2/index.php` is just
50
+ `require '_underscore.php'`, resolved through PHP's `include_path` — there is no relative path
51
+ and no composer entry to follow. Locally, prepend your `_underscore` checkout with
52
+ `set_include_path()` in the harness before the require.
53
+ *On EB:* `worker2/.ebextensions/php_include_underscore.config` writes the same entry into the
54
+ PHP config, and `.ebextensions/git.php` clones `_underscore` next to the app at deploy time
55
+ (branch = `_` + lowercased `ENVIRONMENT`, per
56
+ [the api2 write-up of the same mechanism](../../api2/features/environment-variable-drives-underscore-branch.md)).
57
+
58
+ 3. **Set `ENVIRONMENT` and add a local `Config/<name>.ini`.** `_Environment::$name` comes from the
59
+ `ENVIRONMENT` env var and selects `worker2/Config/<name>.ini` (the repo already carries one
60
+ `dev-<developer>-*.ini` per developer — clone the nearest). It needs at minimum:
61
+ - `[database]` pointed at `localhost`;
62
+ - a complete `[netsuite]` block. The key naming the signing certificate must be repointed from
63
+ the **server** path to your local copy under `worker2/Config/` — the shipped value is an
64
+ absolute server path and will not resolve on a laptop.
65
+
66
+ 4. **Create a local `ClientLogs` schema — authentication writes to it.**
67
+ `_Component_Api_Netsuite::authenticate()` logs the OAuth token request to the client-logs
68
+ database, so NetSuite auth **fails on a machine that has no `ClientLogs`**, before any query
69
+ runs. Seed one from `dbchanges2/Logs_Client/2026-04-08_BLANK_CLIENT_LOGS_DATABASE.SQL`.
70
+ This is the step that looks like a NetSuite credential problem and is not.
71
+
72
+ 5. **Create a minimal local `Forecast` fixture.** For customer work only the `Customers` table is
73
+ needed — build just the tables the action touches rather than cloning the whole schema.
74
+
75
+ Then invoke the action **in-process** (call the static method directly). Do **not** route it
76
+ through `_Worker::runTask` locally: the dev config's `[cloud] aws_worker_queue_url` points at the
77
+ **production** SQS queue.
78
+
79
+ ## Gotchas / known issues
80
+
81
+ - **⚠ dev-sandbox's `Forecast` schema is ~6 months stale — do not test recent Forecast work there.**
82
+ Confirmed 2026-08-19: dev-sandbox has `Employees.departmentId` (a 2026-02 migration) but is
83
+ **missing** `Sales.amountDue` (2026-06-09), `Opportunities.clickupTaskId` (2026-07-20) and
84
+ `Customers.accountType` (2026-07-14) — all merged in `dbchanges2` `_main`. The `Forecast/` folder
85
+ appears not to have been applied to dev-sandbox since roughly February 2026. A dry run there dies
86
+ on the missing column, which reads like a code bug. Use a local fixture instead, and see
87
+ [repairing non-prod drift](../../dbchanges2/workflows/nonprod-metadata-drift-repair.md).
88
+ - **⚠ SECURITY — live credentials are committed in this repo; they are locations to fix, not
89
+ settings to copy.** Do not paste any of these values anywhere, including a knowledge doc:
90
+ - `worker2/.ebextensions/git.json` carries a **live GitHub personal access token**, used so
91
+ Elastic Beanstalk can clone `_underscore` into the staging directory at deploy time. It is in
92
+ **git history**, so replacing the file is *not* sufficient — the token must be **rotated**.
93
+ - `worker2/Config/production.ini` carries live production database credentials.
94
+ - `test/@dave/CLAUDE.md` contains a production database password inline in an example.
95
+ Recommended direction (not yet actioned): AWS Secrets Manager or EB environment properties, with
96
+ the ini files reduced to non-secret settings. Raise this with a senior before a rotation, since
97
+ the deploy clone breaks the moment the token changes.
98
+ - **A local config that "works" proves nothing about the EB tier.** Branch and config both derive
99
+ from `ENVIRONMENT`, not from the EB environment's name — the api2 doc linked above documents a
100
+ live tier where the two disagree.
101
+
102
+ ## Change history
103
+
104
+ - 2026-08-19 — Created while testing the customer account-type sync (TRUE-80206) with no usable
105
+ shared environment: recorded the five-step local boot (composer install with the `ext-zip` opt-out,
106
+ `set_include_path()` for `_underscore` vs. EB's `php_include_underscore.config`, `ENVIRONMENT` →
107
+ `Config/<name>.ini` with a local `[database]` and a repointed NetSuite certificate path, the
108
+ **`ClientLogs` schema that `_Component_Api_Netsuite::authenticate()` requires before any query**,
109
+ and a `Customers`-only `Forecast` fixture). Recorded that **dev-sandbox's `Forecast` schema is ~6
110
+ months behind `dbchanges2` `_main`** (missing `Sales.amountDue`, `Opportunities.clickupTaskId`,
111
+ `Customers.accountType`), which is why a local fixture is the fallback. Flagged committed live
112
+ credentials in `.ebextensions/git.json` (GitHub token — in git history, **rotation required**),
113
+ `Config/production.ini` and `test/@dave/CLAUDE.md`. (kyalamarthi)
114
+
115
+ ## Related docs
116
+
117
+ - [Running 2.0 code from a bare CLI script](../../_underscore/features/cli-script-bootstrap.md) — the
118
+ framework-level bootstrap, transaction and `sql_mode` traps.
119
+ - [NetSuite Supporting-Record Backfill Worker](../features/netsuite-supporting-record-backfill-worker.md)
120
+ — the action this harness was built to test.
121
+ - [Repairing non-prod metadata drift](../../dbchanges2/workflows/nonprod-metadata-drift-repair.md).
122
+ - [`ENVIRONMENT` decides the `_underscore` branch and Config file](../../api2/features/environment-variable-drives-underscore-branch.md)
123
+ — the api2 sibling of steps 2–3.
@@ -19,7 +19,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
19
19
  ## 2.0 framework
20
20
 
21
21
  - **_underscore** (_Underscore) _(framework core)_ — 60 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
- - **worker2** (Worker) — 51 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
22
+ - **worker2** (Worker) — 53 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 24 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 8 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
25
25
  - **toga2-supply** (TOGa Supply) — 7 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.618",
3
+ "version": "1.0.620",
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",