toga-ai 1.0.813 → 1.0.815

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.
@@ -11,6 +11,7 @@
11
11
  | [Assortment Name Translation (AssortmentTranslations sidecar)](features/assortment-name-translation.md) | Serves Assortment (product-grouping) **names** in multiple languages by adding a per-language **sidecar** table `AssortmentTranslations`, reusing the platform's |
12
12
  | [Asynchronous Query Execution (writes-only, via Worker)](features/async-query-execution.md) | `_Query` can run a **write** query asynchronously so a long/slow write does not hold a request-scoped DB connection open long enough to hit **"MySQL server has |
13
13
  | [FIELD_AUTOINCREMENT record numbering (SA / TA prefixes, NULL-only trigger, no padding)](features/autoincrement-record-numbering.md) | A human-facing record number (`SA100001`, `TA100000`) is generated by the ORM, not by MySQL. |
14
+ | [Calculated-column filter options (`<field>FilterOptions` model static → table meta)](features/calculated-column-filter-options.md) | A seam that lets a **calculated (`FIELD_SQL`) table column supply its own filter options**, so the front end renders a multi-select instead of a free-text searc |
14
15
  | [FIELD_SQL calculated fields — the underscore-prefix + same-name-method contract](features/calculated-sql-fields.md) | A `FIELD_SQL` (calculated) field on a `_Model` is bound by a **two-part contract that `_Model` enforces by throwing at model-construction time**, not by convent |
15
16
  | [Carrier Shipping Labels (UPS/FedEx) & NetSuite Item Fulfillment](features/carrier-shipping-labels.md) | Backend mechanics behind TOGa Supply's Fulfill & Ship: buying a carrier label (UPS/FedEx), persisting it, and creating the NetSuite Item Fulfillment with tracki |
16
17
  | [Running 2.0 code from a bare CLI script (bootstrap + transactions)](features/cli-script-bootstrap.md) | A throwaway CLI script (a data check, a backfill dry-run, a render harness) that wants the real 2.0 framework — `_Model`, `_Query`, `_Database` — is **not** the |
@@ -0,0 +1,128 @@
1
+ ---
2
+ title: Calculated-column filter options (`<field>FilterOptions` model static → table meta)
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-09-15
10
+ owners: [apeterson]
11
+ files:
12
+ - _underscore/Model/Client/TableView.php
13
+ - _underscore/Model/Compass/SalesOrder.php
14
+ - _underscore/Model.php
15
+ related:
16
+ - sales-order-status-filter-surface.md
17
+ - tableview-joins.md
18
+ - ../../toga-blox/features/table.md
19
+ - ../../api2/features/tableview-field-metadata.md
20
+ ---
21
+
22
+ ## What it is
23
+
24
+ A seam that lets a **calculated (`FIELD_SQL`) table column supply its own filter options**, so the
25
+ front end renders a multi-select instead of a free-text search.
26
+
27
+ Before this, `filterOptions` in table meta was filled in **only** for `TYPE__STATUS` columns, via
28
+ `getStatusFilterOptions()` (a Surface `FILTER_SET` bound to a status vocabulary — see
29
+ [sales-order-status-filter-surface](sales-order-status-filter-surface.md)). That path cannot serve a
30
+ column whose options are **live data** (e.g. the list of active Admin users): seeding a surface
31
+ element per user would go stale constantly.
32
+
33
+ First and only consumer today: the Compass **Assigned Admin** column on the sales-orders table.
34
+
35
+ ## How it works
36
+
37
+ `_Model_Client_TableView::meta()`, in the per-field loop right after
38
+ `$modelFieldConfig = (new $modelName)->getFieldConfig();`, calls
39
+ `$modelName::{$field}FilterOptions()` when **all three** hold:
40
+
41
+ 1. the column has no `filterOptions` yet,
42
+ 2. `isFilterable` is set,
43
+ 3. the model field config type is `_Model::FIELD_SQL`,
44
+
45
+ and the static exists (`method_exists` guard). Suffix constant:
46
+ `_Model_Client_TableView::FILTER_OPTIONS_METHOD_SUFFIX = 'FilterOptions'`.
47
+
48
+ Returning **null** means "no opinion" — the column keeps its free-text search rather than rendering
49
+ an empty dropdown. Same contract as `getStatusFilterOptions()`.
50
+
51
+ **This is not a new convention.** `_underscore/Model.php` (the `FIELD_SQL` case, ~line 135) already
52
+ throws when a `FIELD_SQL` field has no static of the same name — that is why `_assignedAdmin()`
53
+ exists. The seam just adds a `FilterOptions` suffix to the same name-derived rule.
54
+
55
+ It is a **no-op for every existing column**: nothing else declares a `<field>FilterOptions` static.
56
+
57
+ ### The Compass example
58
+
59
+ `_Model_Compass_SalesOrder::_assignedAdminFilterOptions(): ?array` runs a raw `_Query` on
60
+ `_underscore::DB_CLIENT` joining `Users` / `Users_Roles` / `Roles`, filtered
61
+ `Users.isActive = 1 AND Roles.name = ROLE_NAME__ADMIN` (`'Admin'`), and returns
62
+ `[{slug, name}]` where **both** are `CONCAT(firstName, ' ', lastName)`.
63
+
64
+ - **The value must be the NAME, not a uuid.** `_assignedAdmin()` is a correlated subquery that
65
+ resolves to the assignee's `CONCAT(firstName, ' ', lastName)`, so the filter compares against that
66
+ string; a uuid option would match zero rows.
67
+ - **Both Compass tenants are covered by inheritance** — `_Model_Compass_Usa_SalesOrder` and
68
+ `_Model_Compass_Canada_SalesOrder` both extend `_Model_Compass_SalesOrder`. No per-client copy.
69
+ - Verified locally: 13 options for `Client_Compass`, 5 for `Client_CompassCanada`. Compass Canada
70
+ also has a role `Agilant - Administrators`, correctly excluded by the exact `name = 'Admin'` match.
71
+
72
+ ## Why a model static and not a config column
73
+
74
+ A CTO review compared this against adding a `TableViewFields` column holding a query descriptor
75
+ (route + where + label field) and **agreed** on the model static: the descriptor could not express
76
+ the required two-table `Users_Roles` / `Roles` join without growing into a query language inside a
77
+ config column, and it would spend a multi-tenant schema change to serve one column. Revisit the
78
+ generic version at roughly **4–5** such columns, driven by real examples.
79
+
80
+ ## Gotchas
81
+
82
+ - **`filterOptions` alone is the front-end gate.** `PrimaryTableHeaderCell` renders
83
+ `HeaderFilterMultiselect` whenever `isFilterable && filterOptions`, and the free-text
84
+ `HeaderFilterSearch` only when `isFilterable && !filterOptions`. There is **no type gate** on the
85
+ multi-select branch — a `STRING` column with options gets a multi-select. `recordRoute` and
86
+ `surfaceSlug` appear on status columns only because a status column's options come from a
87
+ `FILTER_SET` surface; they are **not** required. `recordRoute` is only a *fallback* fetch
88
+ (`needsFetchedOptions = !!field.recordRoute && !field.filterOptions`), so
89
+ `filterOptions` with `recordRoute: null` is fine. Verified against
90
+ `@agilant/toga-blox 1.1.2-production.144` (the build toga25-supply pins), in `node_modules/dist`.
91
+ - **The `$recordField` may be a `_Model_Client_CustomRecordField`, not a `_Model_Core_RecordField`.**
92
+ The Compass Assigned Admin/Assigned Manager columns are backed by `CustomRecordFields` in the
93
+ **Client** DB: `Client_Compass.TableViewFields` id 192 slug `assigned-admin` →
94
+ `customRecordFieldId` 96 (`recordFieldId` NULL); `Client_CompassCanada.TableViewFields` id 211 →
95
+ `customRecordFieldId` 57. Both custom fields are `field = '_assignedAdmin'`, `type = 'STRING'`,
96
+ `recordId = 14`. Both classes expose `->field` and `->type` identically, so field-name-keyed logic
97
+ works either way — just do not assume a Core RecordField.
98
+ - **Key on the FIELD name, not the column slug.** The slug is `assigned-admin` (hyphen); the model
99
+ field is `_assignedAdmin`. The seam uses the field name.
100
+ - **Type `STRING` (not STATUS/SELECT) is what lets the branch fire** — the existing `switch` in
101
+ `meta()` leaves `filterOptions` null for it.
102
+ - **⚠ Name-as-identifier limit.** The option value is a display name, not a user id. Two admins with
103
+ the same full name collapse into one option, and picking it matches both of their orders.
104
+ `SELECT DISTINCT` dedupes strings, not people. Forced by `_assignedAdmin()` resolving to a name —
105
+ the same pattern `_assignedManager()` already uses.
106
+ - **Matching a role by its human-readable `name` is fragile** (flagged by `sql-reviewer`): a client
107
+ renaming the `Admin` role silently empties the list. A stable role id would be sturdier.
108
+ - **⚠ Filtering a calculated column lands in HAVING, not WHERE.**
109
+ `api2/Component/Api/V2/V2.php` `splitWhereExpressionsIntoWhereHaving` routes a where on a
110
+ calculated field into `HAVING`, so the `_assignedAdmin` correlated subquery is evaluated for every
111
+ row surviving the WHERE, **before LIMIT** — and again for the pagination count query. On a large
112
+ Compass `SalesOrders` table this can be slow, and it will not fail loudly (api2's Apache gateway
113
+ timeout is 1800s). This is pre-existing behaviour of filtering **any** calculated column, not new
114
+ here, and it was **not** measured with `EXPLAIN` at production size. The cheaper target would be
115
+ the indexed `ApprovalDecisions.assignedToUserId` with uuid option slugs — but the current
116
+ `filterOptions` contract has no way to say "filter on a different column than the one displayed",
117
+ so that stays a fallback design.
118
+ - The multi-select's comma-joined `contains` encoding over-includes and breaks on commas — see
119
+ [toga-blox table](../../toga-blox/features/table.md). It affects this column and Order Status alike.
120
+
121
+ ## Change history
122
+ - 2026-09-15 — Built the seam (`FILTER_OPTIONS_METHOD_SUFFIX` + the guarded branch in
123
+ `_Model_Client_TableView::meta()`) and its first consumer,
124
+ `_Model_Compass_SalesOrder::_assignedAdminFilterOptions()` (active Admin users, covering both
125
+ Compass tenants by inheritance). TRUE-81388, `_underscore` commit `7d92b0df`. Recorded that
126
+ `filterOptions` alone gates the front-end multi-select, that these columns are `CustomRecordFields`
127
+ rather than Core `RecordFields`, the name-as-identifier limit, and the HAVING performance risk.
128
+ (apeterson)
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-25
9
+ updated: 2026-09-15
10
10
  owners: [apeterson]
11
11
  files:
12
12
  - _underscore/Model/Client/TableView.php
@@ -22,6 +22,7 @@ files:
22
22
  - dbchanges2/Client_Quad/2026-08-24b - SalesOrderStatusFilterHides.sql
23
23
  - dbchanges2/Client_Nychh/2026-08-24b - SalesOrderStatusFilterHides.sql
24
24
  related:
25
+ - calculated-column-filter-options.md
25
26
  - surface-resolver.md
26
27
  - item-fulfillment-stage-lifecycle-and-order-status.md
27
28
  - ../../dbchanges2/features/surface-layer-schema.md
@@ -137,6 +138,11 @@ states from the NetSuite billing sync.
137
138
  metadata DB change (see [toga-blox table](../../toga-blox/features/table.md)).
138
139
 
139
140
  ## Change history
141
+ - 2026-09-15 — Cross-linked the new calculated-column seam: a `FIELD_SQL` column can now emit its own
142
+ `filterOptions` from a `<field>FilterOptions` model static, so `getStatusFilterOptions()` is no
143
+ longer the only producer. `recordRoute`/`surfaceSlug` are status-specific, not requirements for a
144
+ multi-select. See
145
+ [calculated-column-filter-options](calculated-column-filter-options.md). (apeterson)
140
146
  - 2026-08-25 — Built the feature end to end: Core `FILTER_SET` surface 44 + BADGE elements 147–160
141
147
  (labels/colours from the `sales-order-status` vocabulary, per-client hiding via `SurfaceOverrides`
142
148
  `IS_VISIBLE=0`); `_Model_Client_TableView` emits `surfaceSlug` + `filterOptions` on `TYPE__STATUS`
@@ -163,8 +163,25 @@ layer and emits it — see
163
163
  [sales-order-status-filter-surface](../../_underscore/features/sales-order-status-filter-surface.md).
164
164
  **The blox side never derives a surface slug by naming convention; it renders what meta gives it.**
165
165
 
166
+ **`filterOptions` alone is the gate.** `PrimaryTableHeaderCell` renders `HeaderFilterMultiselect`
167
+ when `isFilterable && filterOptions`, and `HeaderFilterSearch` only when
168
+ `isFilterable && !filterOptions` (and the type is not CURRENCY/NUMBER/DATETIME/DATE). There is **no
169
+ type gate** on the multi-select branch — a `STRING` column with options gets a multi-select, and
170
+ `recordRoute` / `surfaceSlug` are **not** required (`recordRoute` is only the fallback fetch:
171
+ `needsFetchedOptions = !!field.recordRoute && !field.filterOptions`). The backend can therefore feed
172
+ a calculated column's options from a model static — see
173
+ [calculated-column-filter-options](../../_underscore/features/calculated-column-filter-options.md).
174
+
166
175
  ## Gotchas
167
176
 
177
+ - **⚠ OPEN — the multi-select filter matches by SUBSTRING, not exactly.** `HeaderFilterMultiselect`
178
+ comma-joins the picked slugs and `src/api/tableData/getDataTableData.ts` turns that into
179
+ `where: { or: [ { field: { contains: value } }, … ] }`. Picking "Jon Smith" also matches
180
+ "Jon Smithson" — it over-includes rows.
181
+ - **⚠ OPEN — an option value containing a comma silently returns zero rows.** It splits into two
182
+ bogus filter terms, with no error. Both bugs are pre-existing and affect **every** multi-select
183
+ column including Order Status; both fix in one place — send a list / exact-equals operator instead
184
+ of a comma-joined `contains`. Not fixed as of 2026-09-15.
168
185
  - **⚠ `useTableData` APPENDS `.uuid` to `additionalData.slug` — unless the slug is on a hardcoded
169
186
  allow-list.** The list is exactly `catalogId`, `customerId`, `Items.catalogId`,
170
187
  `SalesOrderItems.uuid`, `SalesOrders.uuid` (verified in `@agilant/toga-blox`
@@ -238,6 +255,10 @@ layer and emits it — see
238
255
  requires `npm run build` before it takes effect at runtime.
239
256
 
240
257
  ## Change history
258
+ - 2026-09-15 — Recorded that `filterOptions` alone gates the multi-select (no type gate;
259
+ `recordRoute`/`surfaceSlug` not required) and logged two pre-existing multi-select filter bugs:
260
+ substring `contains` matching over-includes rows, and a comma inside an option value returns zero
261
+ rows. Found while adding the Compass Assigned Admin filter (TRUE-81388); neither fixed. (apeterson)
241
262
  - 2026-09-04 — Recorded the `useTableData` **`.uuid` append + hardcoded allow-list** contract
242
263
  (`catalogId`, `customerId`, `Items.catalogId`, `SalesOrderItems.uuid`, `SalesOrders.uuid`,
243
264
  verified in `1.1.2-production.144`). A dotted slug that is not on the list becomes
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-08-31
9
+ updated: 2026-09-15
10
10
  owners: [jcardinal, ajean]
11
11
  files:
12
12
  - worker2/Controller/Index.php
@@ -213,6 +213,16 @@ file `Worker/Team/Github.php`.
213
213
  `_Worker_Infrastructure_Worker_Cleanup`: `WorkerJobs()` deletes `isSuccess=1` rows >90 days
214
214
  (≤1000/run); `WebhookLogs()` deletes `Logs.Webhook` >90 days. Two daily CronJobs at 2 AM.
215
215
 
216
+ **⚠ The OBSERVED `Core.WorkerJobs` retention is ~30 days, not 90 — unexplained.** Measured
217
+ 2026-09-15 in prod: `MIN(dtCreated)` across the **whole** table is **2026-08-16 02:00:46**
218
+ (790,597 rows). Either the cron does not behave as described above, or something else prunes the
219
+ table. **Not investigated** — no cause is asserted here.
220
+
221
+ **Triage consequence: a missing `WorkerJobs` row is NOT proof the job never ran.** Any "there is no
222
+ job for this record, so the webhook never fired" conclusion is invalid past the ~30-day floor. That
223
+ exact wrong call was made and retracted on 2026-09-15. Same failure family as *an empty log table is
224
+ not proof a code path never ran*.
225
+
216
226
  ## Cron actions must return a result on BOTH paths
217
227
 
218
228
  **A new cron action is expected to return a result for a success as well as a failure.** Both
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-02
9
+ updated: 2026-09-15
10
10
  owners: ["ajean"]
11
11
  files:
12
12
  - worker2/Worker/Netsuite/Opportunity.php
@@ -143,6 +143,25 @@ legitimately be running before the column lands. An **ungated write does not deg
143
143
  breaks the ENTIRE opportunity sync**, because `importOpportunity()`'s save names a column that does
144
144
  not exist and *every* NetSuite webhook fails. A label enhancement must not be able to do that.
145
145
 
146
+ **⚠ CORRECTED 2026-09-15 — this gate did not prevent that outcome; it happened anyway, for 9 days.**
147
+ The predicted failure ("it breaks the ENTIRE opportunity sync") occurred 2026-09-01 → 2026-09-10
148
+ with the gate in place, because the gate checks the **DB column** and the failing write went through
149
+ the **ORM**: the column existed, but `_underscore`'s `_Model_Forecast_Opportunity` had not yet
150
+ declared the `endCustomerName` property, so `_Model` rejected the field. **The deploy has two
151
+ independent halves and they ship from different repos** — the dbchanges2 migration *and* the
152
+ `_underscore` model declaration. General rule: **gate on the layer the write actually goes through,
153
+ not the layer that is easiest to query.**
154
+
155
+ So there are now **two** gates (worker2 PR #168, open against `_production` as of 2026-09-15):
156
+
157
+ | Gate | Checks | Use for |
158
+ |---|---|---|
159
+ | `hasEndCustomerColumn()` (unchanged) | `information_schema` column | **raw `_Query` SQL only** — the digest + both backfills |
160
+ | `canWriteEndCustomerNameToModel()` (new) | the above **and** `property_exists(_Model_Forecast_Opportunity::class,'endCustomerName')` | **`importOpportunity()`'s ORM write only** |
161
+
162
+ Each half `error_log`s on its own, naming the repo that is behind. Full incident detail:
163
+ [the outage gotcha on the sync doc](./netsuite-opportunity-sync.md#gotchas--known-issues).
164
+
146
165
  - `information_schema.COLUMNS` check, **memoised per process** in a `static` (the answer cannot
147
166
  change mid-job, and the import runs per webhook) — `static`, not a constant, so a long-lived
148
167
  worker picks the column up on its next job.
@@ -348,6 +367,13 @@ A one-shot rem+add corrector was scoped and deliberately **not** built.
348
367
  injection before they go into the digest.
349
368
 
350
369
  ## Change history
370
+ - 2026-09-15 — **Corrected the deploy gate: `hasEndCustomerColumn()` did NOT protect the ORM write,
371
+ and the sync was down 9 days (2026-09-01 → 2026-09-10).** The column existed; `_underscore`'s
372
+ `_Model_Forecast_Opportunity` had not declared the `endCustomerName` property, so `_Model`
373
+ rejected the field on every `Netsuite/Opportunity/post|put` (611 failed jobs / 310 opportunities).
374
+ Added `canWriteEndCustomerNameToModel()` for the ORM write and scoped `hasEndCustomerColumn()` to
375
+ raw `_Query` callers (worker2 PR #168, open). Lesson: gate on the layer the write goes through —
376
+ the model declaration is a second deploy half and it lives in a different repo. (ajean)
351
377
  - 2026-09-02 — **End Customer now comes from NetSuite `custbody_end_customer`, else mirrors the
352
378
  client (worker2 PR #158 + dbchanges2 PR #477; re-import PR #159 — all merged).** Root cause: End
353
379
  Customer was derived from the opportunity `entity`, which **is the partner** on a partner-routed
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-10
9
+ updated: 2026-09-15
10
10
  owners: ["dfranks", "kyalamarthi", "ajean", "jcardinal"]
11
11
  files:
12
12
  - worker2/Worker/Netsuite.php
@@ -144,6 +144,12 @@ claim on a never-claimed opportunity, so it does **not** contradict the rule tha
144
144
  *Consequence: `updateTask()` is unreachable* below). So when triaging a backlog of
145
145
  `skipped (no presales lead)`, read it as **"queued, awaiting a lead"**, not "lost".
146
146
 
147
+ **Confirmed end-to-end a second time, 2026-09-15.** Opportunity **75504** (NS internalId 7377655,
148
+ `Forecast.Opportunities.id` 94076) had no task; replaying its webhook returned
149
+ `{"db":"updated opportunity 7377655 (id=94076, items=1)","clickup":"skipped (no presales lead)"}`.
150
+ Assigning the Presales Lead in NetSuite produced the task. **This gate is the single most common
151
+ reason a specific opportunity has no ClickUp task** — check it before suspecting the sync.
152
+
147
153
  Shared helpers: `buildCustomFields()` (the field array, used by both create and update),
148
154
  `resolvePresalesUserId()` (cached `Client_True.Users.c_clickupUserIdentifier` lookup → else a
149
155
  `GET /team` roster scan that back-fills the cache via `cacheClickupUser()`).
@@ -306,6 +312,57 @@ Every `Netsuite/Opportunity/post|put` job writes a JSON result into `Core.Worker
306
312
  string literal or a column alias**, so match the `skipped update (update disabled)` verdict with
307
313
  `LIKE '%disabled%'` and alias the column `skippedDisabled`, never `updateDisabled`. See
308
314
  [MCP Tool Usage](../../../../standalone/apps/claude/workflows/mcp-tool-usage.md).
315
+ - **⚠ `Core.WorkerJobs` holds only ~30 days — a missing job row is NOT proof the webhook never
316
+ fired.** Measured 2026-09-15: `MIN(dtCreated)` across the **whole** table is **2026-08-16
317
+ 02:00:46** (790,597 rows). So any "this opportunity has no job, therefore NetSuite never sent it"
318
+ conclusion is invalid past that horizon — a wrong call made and retracted mid-session on
319
+ 2026-09-15. Same failure family as *an empty log table is not proof a code path never ran*. Note
320
+ the **discrepancy**: `worker2/architecture.md` describes the cleanup cron as deleting `isSuccess=1`
321
+ rows older than **90 days**, but the observed floor is ~30 — either the cron's behaviour or that
322
+ description is wrong. Trust the observed value and check before relying on either.
323
+ - **`skipped (no presales lead)` has TWO causes that look identical in the DB.** The gate tests the
324
+ **resolved email**, not the employee reference — so it fires both when NetSuite's
325
+ `custbody_ctc_solution_architect` is **unset** *and* when it is set to an employee whose record has
326
+ **no email**. Do not read the verdict as "the field is empty in NetSuite."
327
+ - **Scale reference for triage** — prod `Forecast.Opportunities` census, 2026-09-15. Most
328
+ opportunities have no ClickUp task, and that is normal:
329
+
330
+ | `netsuiteStatus` | Total | No task | With task |
331
+ |---|---|---|---|
332
+ | In Progress | 2,506 | 2,025 | 481 (~19%) |
333
+ | Closed - Won | 52,931 | 52,808 | 123 |
334
+ | Closed - Lost | 19,235 | 19,042 | 193 |
335
+
336
+ ### Backfill = replay the webhook, and bulk replay is safe
337
+
338
+ Re-firing the ingestion endpoint is the repair mechanism for any opportunity the sync missed:
339
+
340
+ ```
341
+ curl -X POST https://webhook.togahub.com/netsuite -H 'Content-Type: application/json' \
342
+ -d '{"recordType":"opportunity","eventType":"edit","internalId":<id>}'
343
+ ```
344
+
345
+ **Why bulk replay is safe** (exercised at scale 2026-09-15 — 264 replays at 0.5 s spacing, all 264
346
+ accepted by the ingestion Lambda; 265 jobs succeeded, 1 failed, 14 ClickUp tasks created, 1
347
+ `already claimed`, 250 `no presales lead`):
348
+
349
+ - The atomic `clickupTaskId` claim makes an already-claimed opportunity return
350
+ `skipped (already claimed)` and write **nothing** to ClickUp — **no duplicate tasks**.
351
+ - An opportunity with no presales lead returns `skipped (no presales lead)` and stays queued.
352
+ - `importOpportunity()` is self-healing, so the replay also repairs the Forecast row.
353
+
354
+ **A `404` on replay is the correct signal for a NetSuite-deleted opportunity, not a defect.** The
355
+ single failure in that run (NS internalId **7442501**) was NetSuite REST HTTP 404
356
+ *"The opportunity does not exist"* — the record had been deleted in NetSuite, which is also why it
357
+ was the one row missing from Forecast.
358
+
359
+ ### Open item — item-sync watchdog timeouts (NOT investigated)
360
+
361
+ `Netsuite/InventoryItem/put|post` and `Netsuite/NonInventoryItem/*` logged **427 failures** over
362
+ 2026-09-01 → 2026-09-10, all with output `watchdog: exceeded maxExecutionTime without completing`.
363
+ **Different root cause from the `endCustomerName` outage** (execution time, not a model error) and
364
+ deliberately out of scope on 2026-09-15. Recorded as a known open item; needs its own ticket. No
365
+ cause is asserted here.
309
366
 
310
367
  ### ⚠ A healthy sync reads as dead: new tasks do NOT stay in "Presales Qualification"
311
368
 
@@ -341,6 +398,17 @@ Full list ids and the API constraints are in
341
398
  re-derive them here. **Practical rule is unchanged:** verify by task id or across the whole
342
399
  Opportunities folder, never by one list.
343
400
 
401
+ **More evidence, 2026-09-15 — and a counter-example that pins down *when* the Automation moves a
402
+ task.** After the outage fix, new tasks again landed spread across folder `90117060687`
403
+ ("Opportunities"), space `90113928591`, **none** in Presales Qualification (`901111987449`):
404
+ `868m40zf5` (opp 76045 Adyen, 9/10) → Pending Client Response · `868m484vn` (opp 76021 Seminole
405
+ Gaming, 9/11) → BU Discovery (`901112518588`) · `868m5e958` (opp 75740, 9/15) and `868m5fmc3`
406
+ (opp 76115, 9/15) → Contract Negotiations (`901111987463`). **But the 14 tasks created by the
407
+ backfill replay the same day DID stay in Presales Qualification** (e.g. `868m5gexk` opp 72119,
408
+ `868m5ghgt` opp 76051). So the Automation moves a task **only when the stage dropdown changes**, not
409
+ on creation — a task sitting in Presales Qualification means its stage has not moved since birth,
410
+ not that the mover is broken.
411
+
344
412
  **The Automation is load-bearing.** NetSuite writes the dropdown and ClickUp→NetSuite reads the
345
413
  human's list move; both directions run through it. Treat any change to that Automation as a
346
414
  **breaking change** to this integration.
@@ -383,26 +451,25 @@ the legacy cron's OPPORTUNITIES section:
383
451
  letter, so the handler runs a one-row SuiteQL query for it.
384
452
  - **`endCustomerName`** (added 2026-09-01, `dbchanges2/Forecast/2026-09-01a …`) holds NetSuite
385
453
  `custbody_end_customer`'s `refName` — a **name, not a FK**, because nothing upserts an
386
- end-customer `Customers` row. The write and both reads are gated on the column existing
387
- (`hasEndCustomerColumn()`), since the migration is applied by hand and an unknown column would fail
388
- the whole query and break every opportunity webhook. See
454
+ end-customer `Customers` row. **CORRECTED 2026-09-15 this bullet used to say the write and both
455
+ reads are "gated on the column existing (`hasEndCustomerColumn()`)", which reads as "safe". It was
456
+ not: that gate checks the DB column and the model write took the sync down for 9 days anyway.**
457
+ There are now **two** gates, and they are not interchangeable:
458
+ - `hasEndCustomerColumn()` — `information_schema` column check. Correct **only** for raw `_Query`
459
+ SQL (the digest and the two backfills).
460
+ - `canWriteEndCustomerNameToModel()` — `hasEndCustomerColumn() && property_exists(
461
+ _Model_Forecast_Opportunity::class, 'endCustomerName')`. The **only** gate valid for
462
+ `importOpportunity()`'s ORM write.
463
+
464
+ See the *`endCustomerName` 9-day outage* gotcha below and
389
465
  [the labels doc](./netsuite-opportunity-client-labels.md).
390
- - **⚠ `hasEndCustomerColumn()` guards the DB COLUMN, NOT the ORM MODEL field and that gap took the
391
- whole opportunity sync down for 8 days (2026-09-02 2026-09-10).** The guard does a `SHOW COLUMNS`
392
- on `Forecast.Opportunities`, so once the migration was applied it passed and the handler ran
393
- `$opportunity->endCustomerName = …`. But the 2.0 model `_Model_Forecast_Opportunity`
394
- (`_underscore/Model/Forecast/Opportunity.php`) never **declared** the field, so `_Model.__set`
395
- (`_underscore/Model.php`) threw *"There is no field called 'endCustomerName' in the
396
- '_Model_Forecast_Opportunity' model."* on **every** `Netsuite/Opportunity/post|put` 100% failure,
397
- every opportunity import dead. `Core.WorkerJobs` census: Sep 1 = 115 ok / 60 fail (last good day),
398
- Sep 2 → Sep 10 = 0 ok / 100% fail (61 fails on Sep 10 alone). This was a **half-deploy**: the
399
- migration + the handler write shipped, the model field declaration did not.
400
- **Durable rule: a new column that the handler writes must ship THREE things together — the
401
- `dbchanges2` migration, the handler write, AND the `_Model` field declaration.** A column-existence
402
- guard (`SHOW COLUMNS`) proves nothing about the ORM; both ORMs enumerate *declared* properties, so an
403
- undeclared field is a hard `__set` throw, not a silent skip. Fixed 2026-09-10 by declaring
404
- `public $endCustomerName = self::FIELD_CHAR;` (matching sibling char fields clickupTaskId/title/memo
405
- over the varchar column); deployed to worker2 `_production`, sync recovered.
466
+ - **Durable rule from the outage: a new column the handler writes must ship THREE things together
467
+ the `dbchanges2` migration, the handler write, AND the `_Model` field declaration.** A
468
+ column-existence guard proves nothing about the ORM; both ORMs enumerate *declared* properties, so
469
+ an undeclared field is a hard `__set` throw, not a silent skip. Fixed 2026-09-10 by declaring
470
+ `public $endCustomerName = self::FIELD_CHAR;` (matching sibling char fields
471
+ `clickupTaskId`/`title`/`memo` over the varchar column). Full incident detail in the
472
+ *`endCustomerName` 9-day outage* gotcha belowdo not restate it here.
406
473
  - Writes use raw `_Query` against `_underscore::DB_FORECAST` and **must**
407
474
  `_Database::transactionCommit(DB_FORECAST)` (lazy-transaction gotcha).
408
475
 
@@ -439,6 +506,40 @@ None — platform-wide Forecast sync.
439
506
 
440
507
  ## Gotchas / known issues
441
508
 
509
+ - **⚠ The `endCustomerName` 9-day total outage — a column-existence gate does NOT protect an ORM
510
+ write (2026-09-01 → 2026-09-10).** Every `Netsuite/Opportunity/post|put` job threw
511
+ `There is no field called 'endCustomerName' in the '_Model_Forecast_Opportunity' model.`
512
+ (`_underscore/Model.php:498`) from 2026-09-01 12:01 to 2026-09-10 15:48 CDT. **The whole
513
+ NetSuite→Forecast→ClickUp opportunity sync was dead for 9 days and every job was red.**
514
+ - **Cause — a deploy split across TWO repos.** worker2 shipped the `$values['endCustomerName']`
515
+ write (PR #158) while `_underscore/Model/Forecast/Opportunity.php` had not yet declared the
516
+ property. `_Model` builds its field map from **declared properties** in the constructor, so
517
+ `__set` rejected the unknown field. The DB column existed (the migration had been applied) — the
518
+ missing half was the model declaration, in a different repo.
519
+ - **Blast radius:** the throw lands in `importOpportunity()` **before** the Forecast save and
520
+ **before** `maybeCreateClickupTask()`, so affected opportunities got **neither** a Forecast row
521
+ **nor** a ClickUp task. Measured in prod `Core.WorkerJobs`: **611 failed jobs, 310 distinct
522
+ opportunities, 264 still with no ClickUp task**, 1 (NS internalId 7442501) absent from Forecast.
523
+ - **Ended by `_underscore` commit `34f76ee5` "Add end customer field to Opportunity"** (Jeff
524
+ Cardinal, 2026-09-10 15:51:31 CDT — `public $endCustomerName = self::FIELD_CHAR;`) — three
525
+ minutes after the last failure. Per-day `WorkerJobs` census: Sep 1 = 115 ok / 60 fail (the
526
+ failures start midday, which is why this is written up as both "8 days" and "9 days"),
527
+ Sep 2 → Sep 10 = **0 ok / 100% fail**.
528
+ - **The lesson, and it generalizes: gate on the layer the write actually goes through, not the
529
+ layer that is easiest to query.** `hasEndCustomerColumn()`'s own docblock predicted this exact
530
+ failure ("it breaks the ENTIRE opportunity sync") and still did not stop it, because it ran
531
+ `SHOW COLUMNS` against `Forecast.Opportunities` while the write went through the ORM. A column
532
+ guard protects raw SQL only. **A model-declaration gap is a second, independent half of the
533
+ deploy, and it ships from a different repo.**
534
+ - **Fix (worker2 PR #168, open against `_production` as of 2026-09-15, not yet merged).**
535
+ `hasEndCustomerColumn()` is byte-identical and now documented as being for **raw `_Query`
536
+ reads/writes only** — its three callers (`OpportunityStakeholderDigest.php:180`,
537
+ `OpportunityStakeholderBackfill.php:579`, `OpportunityEndCustomerBackfill.php:91`) all build
538
+ plain SQL. New `canWriteEndCustomerNameToModel()` = `hasEndCustomerColumn() && property_exists(
539
+ _Model_Forecast_Opportunity::class, 'endCustomerName')` is used **only** by
540
+ `importOpportunity()`. Each half `error_log`s separately, naming the repo that is behind. No
541
+ migration. **No regression test** — worker2 has no test coverage under `Worker/` to hang one off
542
+ (only `Controller/CompassPeopleTest.php` and `Controller/ClickupDesignTest.php` exist).
442
543
  - **`_Model::load()` returns TRUE only on an EXACTLY-ONE match — a duplicate-row self-amplifier in
443
544
  any load-then-upsert handler.** `_Model::load()` (`_underscore/Model.php:788–808`) returns FALSE
444
545
  for **both** 0 rows **and** 2+ rows — it succeeds only when the lookup matches exactly one row.
@@ -775,6 +876,27 @@ deprecated** for production opportunity code.
775
876
  blocked on the Aaron stakeholder decision noted above.
776
877
 
777
878
  ## Change history
879
+ - 2026-09-15 — **Root-caused a 9-day TOTAL outage of the opportunity sync and split the deploy gate
880
+ (worker2 PR #168, open).** Same incident jcardinal fixed on the `_underscore` side below; this
881
+ entry adds the worker2-side gate and the measured blast radius. From 2026-09-01 12:01 to
882
+ 2026-09-10 15:48 CDT every `Netsuite/Opportunity/post|put` threw `There is no field called
883
+ 'endCustomerName' in the '_Model_Forecast_Opportunity' model` — worker2 shipped the write
884
+ (PR #158) before `_underscore` declared the property, and `_Model` builds its field map from
885
+ declared properties. **611 failed jobs / 310 opportunities / 264 left with no ClickUp task** (1,
886
+ NS internalId 7442501, absent from Forecast); ended by `_underscore` `34f76ee5`. The existing
887
+ `hasEndCustomerColumn()` gate did not help because it checks the **DB column** while the write
888
+ goes through the **ORM** — corrected that claim in *Data model* and added
889
+ `canWriteEndCustomerNameToModel()` (`hasEndCustomerColumn() && property_exists(...)`) used only by
890
+ `importOpportunity()`; `hasEndCustomerColumn()` is now scoped to raw `_Query` callers.
891
+ **Also recorded:** `Core.WorkerJobs` retains only **~30 days** (MIN `dtCreated` 2026-08-16), so a
892
+ missing job row proves nothing — and that contradicts architecture.md's 90-day cleanup
893
+ description; **bulk webhook replay is safe** (264 replays, 0 duplicate tasks — the claim, the
894
+ presales gate and self-healing import make it so) and a NetSuite **404 on replay = the record was
895
+ deleted**; `skipped (no presales lead)` has **two** causes (field unset, or the employee has no
896
+ email); a fresh `Forecast.Opportunities` census; more evidence for the list-visibility trap plus
897
+ the counter-example that the Automation moves a task **only on a stage-dropdown change**, not on
898
+ creation. Flagged an unrelated open item: **427** `Netsuite/(Non)InventoryItem/*` watchdog
899
+ execution-time failures in the same window, not investigated. (ajean)
778
900
  - 2026-09-10 — **Fixed an 8-day total opportunity-sync outage caused by a half-deploy: a missing ORM
779
901
  model field.** `Forecast.Opportunities.endCustomerName` (column added ~Sep 1) and the worker2 handler
780
902
  write shipped, but `_Model_Forecast_Opportunity` (`_underscore/Model/Forecast/Opportunity.php`) never
@@ -18,7 +18,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
18
18
 
19
19
  ## 2.0 framework
20
20
 
21
- - **_underscore** (_Underscore) _(framework core)_ — 84 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
+ - **_underscore** (_Underscore) _(framework core)_ — 85 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 69 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 26 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 19 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.813",
3
+ "version": "1.0.815",
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",