toga-ai 1.0.813 → 1.0.814

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ project: 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.813",
3
+ "version": "1.0.814",
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",