toga-ai 1.0.774 → 1.0.775

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.
@@ -2,4 +2,4 @@
2
2
 
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
- | [NetSuite → ClickUp Opportunity Sync](features/netsuite-clickup-opportunity-sync.md) | NetSuite Opportunities are pushed into the ClickUp **Opportunities** list (list id `901111987449`, in the Opportunity/RFP Hub space `90113928591`) as tasks name | webhook/_/webhook/netsuite.php, webhook/api.php, api2/Webhook/Netsuite.php, worker2/Worker/Netsuite.php, api2/Controller/Index.php |
5
+ | [NetSuite → ClickUp Opportunity Sync](features/netsuite-clickup-opportunity-sync.md) | > **⚠ CORRECTED 2026-09-04 — parts of this doc were factually wrong and actively misleading.** > Two claims below have been retracted in place: that > `worker2/ | webhook/_/webhook/netsuite.php, webhook/api.php, api2/Webhook/Netsuite.php, worker2/Worker/Netsuite.php, api2/Controller/Index.php |
@@ -6,17 +6,33 @@ project: Webhook
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-17
10
- owners: ["dfranks"]
9
+ updated: 2026-09-04
10
+ owners: ["dfranks", "ajean"]
11
11
  files:
12
12
  - webhook/_/webhook/netsuite.php
13
13
  - webhook/api.php
14
14
  - api2/Webhook/Netsuite.php
15
15
  - worker2/Worker/Netsuite.php
16
16
  - api2/Controller/Index.php
17
- related: []
17
+ related:
18
+ - ../../../2.0/apps/worker2/features/netsuite-opportunity-sync.md
18
19
  ---
19
20
 
21
+ > **⚠ CORRECTED 2026-09-04 — parts of this doc were factually wrong and actively misleading.**
22
+ > Two claims below have been retracted in place: that
23
+ > `worker2/Worker/Netsuite/Opportunity.php` and `maybeCreateClickupTask()` **do not exist**, and that
24
+ > the integration has **"no dedup of any kind."** Both files exist and are git-active, and the 2.0
25
+ > path carries an **atomic DB claim** on `Forecast.Opportunities.clickupTaskId` plus folder-wide
26
+ > dedup. Those two claims are why a later session opened with a contradiction about which app owns
27
+ > the receiver. Canonical detail:
28
+ > [NetSuite ↔ ClickUp Opportunity Sync (worker2)](../../../2.0/apps/worker2/features/netsuite-opportunity-sync.md).
29
+ >
30
+ > **Scope caveat on everything else here:** the 1.0 `webhook` repo is **not checked out** on the
31
+ > machine used for the 2026-09-04 review and could not be found anywhere on disk, so the claims in
32
+ > this doc about the *live 1.0 receiver* are **unverified as of that date**. The NetSuite-side
33
+ > content (scheduled script **2412**, UserEvent **2493**, the `OPP <id>` stub root cause and its fix)
34
+ > was **not** in question and stands.
35
+
20
36
  ## Summary
21
37
  NetSuite Opportunities are pushed into the ClickUp **Opportunities** list (list id `901111987449`,
22
38
  in the Opportunity/RFP Hub space `90113928591`) as tasks named `<oppNumber> — <customerNumber>
@@ -74,23 +90,32 @@ the assignment fires 2493 in `edit` (full-record) context → proper name (see G
74
90
  None — single internal Agilant/True integration.
75
91
 
76
92
  ## Gotchas / known issues
77
- - **Create-only, NO dedup of any kind (confirmed 2026-07-17).** `Webhook_NetSuite::post()`
78
- json-decodes the payload and calls `createTask()`, which POSTs a **new** task to ClickUp
79
- list `901111987449` **unconditionally on every delivery** (writing link `db_client`). There
80
- is no idempotency check whatsoever — so it double-creates a task on **any** repeat or
81
- concurrent delivery, not only concurrent ones.
93
+ - **Create-only with no dedup *in this 1.0 handler* — but the claim "NO dedup of any kind" was
94
+ WRONG and is retracted (2026-09-04).** As originally written (2026-07-17) this bullet asserted the
95
+ integration as a whole had "no idempotency check whatsoever." That is false at the platform level:
96
+ the **2.0** path (`worker2/Worker/Netsuite/Opportunity.php`) carries a **race-safe atomic claim** —
97
+ a guarded `UPDATE Opportunities SET clickupTaskId=… WHERE … AND clickupTaskId IS NULL` — plus
98
+ folder-wide dedup by `Opportunity #`. What remains true, scoped correctly: **`Webhook_NetSuite::post()`
99
+ in the 1.0 `webhook` repo** json-decodes the payload and calls `createTask()`, which POSTs a new
100
+ task **unconditionally on every delivery** (writing link `db_client`), so *that* handler
101
+ double-creates on any repeat or concurrent delivery. Unverified since 2026-09-04 (repo not checked
102
+ out).
82
103
  - **Live payload carries no immutable opportunity identifier (confirmed 2026-07-17).** The
83
104
  inbound body has **only**: `name`, `description`, `presalesLead`, `salesRepText`, `token`.
84
105
  It has **no NetSuite opportunity internal id and no opportunity/customer number**, so there
85
106
  is no stable key available in this handler to dedup on today. Any dedup fix must first get
86
107
  NetSuite (script 2493) to include an immutable id in the payload, or derive one another way.
87
- - **Where the swapped Opp#/Cust# mapping actually lives.** The
88
- `OPPORTUNITY_NUMBER ← customerNumber` / `CUSTOMER_NUMBER ← opportunityNumber` swap is in the
89
- **dormant 2.0 port** `worker2/Worker/Netsuite.php` (class `_Worker_NetSuite`, ~lines 116–121),
90
- which is **NOT live**. The live `webhook/_/webhook/netsuite.php` payload doesn't even carry
91
- those number fields (see above). There is **no** `worker2/Worker/Netsuite/Opportunity.php` and
92
- **no** `maybeCreateClickupTask()` method anywhere — earlier plans referencing those paths were
93
- citing files that do not exist.
108
+ - **Where the swapped Opp#/Cust# mapping lives — with the "these files don't exist" claim
109
+ RETRACTED (2026-09-04).** The `OPPORTUNITY_NUMBER ← customerNumber` /
110
+ `CUSTOMER_NUMBER ← opportunityNumber` swap is in the **dormant 2.0 port**
111
+ `worker2/Worker/Netsuite.php` (class `_Worker_NetSuite`, ~lines 116–121). That part stands.
112
+ **What was wrong:** this bullet claimed there is "**no** `worker2/Worker/Netsuite/Opportunity.php`
113
+ and **no** `maybeCreateClickupTask()` method anywhere," and that plans citing them were citing
114
+ fictional files. **Both exist and are git-active.** `_Worker_Netsuite_Opportunity` is the current
115
+ 2.0 handler — it does the Forecast upsert, holds the atomic `clickupTaskId` claim, and
116
+ `maybeCreateClickupTask()` is its ClickUp entry point. It also **fixed** the swap (2026-06-16).
117
+ Do not use this bullet to conclude the 2.0 path is vapour; read
118
+ [the worker2 doc](../../../2.0/apps/worker2/features/netsuite-opportunity-sync.md).
94
119
  - **`OPP <internalId>` degenerate tasks (root-caused & confirmed 2026-06-15).** A task named just
95
120
  `OPP 7147699` with no description/fields. The name is built by NetSuite and relayed verbatim;
96
121
  the opps are **fully populated** in NetSuite (so it's not missing data, not a TOGA bug). Cause:
@@ -148,6 +173,16 @@ Done 2026-06-15 for opps 7147699/7147698/7147472/6382879 → 4 proper tasks crea
148
173
  (task ids `868k0tr41`, `868k0tr16`, `868k0rwtp`, `868k0h44m`) were archived in ClickUp afterward.
149
174
 
150
175
  ## Change history
176
+ - 2026-09-04 — **Corrected two factually wrong claims (retracted in place; no code changed).** This
177
+ doc asserted that `worker2/Worker/Netsuite/Opportunity.php` and `maybeCreateClickupTask()` "do not
178
+ exist" and that the integration has "no dedup of any kind." Both files exist and are git-active,
179
+ and the 2.0 path carries an **atomic DB claim** on `Forecast.Opportunities.clickupTaskId` plus
180
+ folder-wide dedup; the create-only/no-dedup statement is true only of the **1.0 `webhook` handler**
181
+ and has been rescoped to it. These two claims were the reason a later session opened with a
182
+ contradiction about which app owns the receiver. Added a scope caveat: the 1.0 `webhook` repo is
183
+ **not checked out** on the reviewing machine and could not be found on disk, so this doc's claims
184
+ about the live 1.0 receiver are **unverified as of 2026-09-04**; the NetSuite-side script 2412/2493
185
+ content was never in question and stands. (ajean)
151
186
  - 2026-07-17 — Confirmed against live code: `createTask()` POSTs to list `901111987449`
152
187
  **unconditionally with no dedup** (double-creates on any repeat/concurrent delivery); the
153
188
  live payload carries only `name`/`description`/`presalesLead`/`salesRepText`/`token` — **no
@@ -8,7 +8,7 @@
8
8
  | [Compass Manager Approval Reminder Emails (1.0 worker crons)](features/compass-manager-approval-reminder-emails.md) | Two 1.0 worker crons nag approvers about sales orders still waiting on a decision — one per Compass tenant. | worker/crons/toga2/compass/compass_email_reminders.php, worker/crons/toga2/compasscanada/compass_email_reminders.php, worker/schedules/cron.worker.sync.json, worker1.5/crons/toga2/compass/compass_email_reminders.php, worker1.5/schedules/cron.worker.json |
9
9
  | [Compass Partial In-Transit & Delivered Emails (per package)](features/compass-partial-in-transit-delivered-emails.md) | Compass USA and Compass Canada send a **per-package** in-transit email (and a matching delivered email) instead of one email listing the whole order. | worker/crons/toga2/compass/update_salesorder_status_from_odp.php, worker/crons/toga2/compass/send_delivered_email.php, worker/crons/toga2/compasscanada/update_salesorder_status_from_g&t.php, worker/crons/toga2/compasscanada/send_delivered_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_delivered_email.php, library/app/client/compasscanada.php, worker/schedules/cron.worker.sync.json, worker2/Worker/Monitor/Compass.php, dbchanges2/Client_Compass/2026-09-03 - compass_intransit_email_backlog_backfill.sql |
10
10
  | [Elite TOGA 2.0 → TOGaDeskSupport Standalone Attachment Sync](features/elite-togadesk-attachment-sync.md) | `sync_togadesk_elite_attachments.php` is a standalone cron (every 5 minutes) that syncs file attachments from TOGA 2.0 into TOGaDeskSupport for Elite. | worker/crons/toga2/elite/sync_togadesk_elite_attachments.php, worker/crons/toga2/elite/test_sync_togadesk_elite_attachments.php |
11
- | [Forecast2 ↔ NetSuite Reconciliation & Trueup Tooling](features/forecast2-netsuite-reconciliation.md) | CLI tools to **audit** and **repair** drift between the production `Forecast` DB (core2) and NetSuite. | test/@dave/checker.php, worker2/Component/Forecast/SaleImport/SaleImport.php, test/@dave/looper.php, test/@dave/reconcile_netsuite_totals.php, test/@dave/fixer.php, tools/bin/forecast/fixer.php, tools/bin/forecast/checker.php, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.php, test/@dave/reconcile_drift_2023plus.php, test/@dave/probe_invoice_gap_2026.php, test/@dave/probe_creditmemo_gap_detail.php, test/@dave/trueup_open_orders.php, test/@dave/loop_trueup_open_orders.php, test/@dave/trueup_opportunities.php, test/@dave/probe_sales_gap_direct.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_profit_invoices.php, test/@dave/probe_profit_gap.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_open_orders.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/schedules/cron.worker.infrastructure.json |
11
+ | [Forecast2 ↔ NetSuite Reconciliation & Trueup Tooling](features/forecast2-netsuite-reconciliation.md) | CLI tools to **audit** and **repair** drift between the production `Forecast` DB (core2) and NetSuite. | test/@dave/checker.php, worker2/Component/Forecast/SaleImport/SaleImport.php, test/@dave/looper.php, test/@dave/reconcile_netsuite_totals.php, test/@dave/fixer.php, tools/bin/forecast/fixer.php, tools/bin/forecast/checker.php, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.php, test/@dave/reconcile_drift_2023plus.php, test/@dave/probe_invoice_gap_2026.php, test/@dave/probe_creditmemo_gap_detail.php, test/@dave/trueup_open_orders.php, test/@dave/loop_trueup_open_orders.php, test/@dave/trueup_opportunities.php, test/@dave/probe_sales_gap_direct.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_profit_invoices.php, test/@dave/probe_profit_gap.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_open_orders.php, worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_opportunities.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/schedules/cron.worker.infrastructure.json |
12
12
  | [Forecast2 Supporting-Records Nightly Import (1.0 cron) — Customers & ConsolidatedCustomers](features/forecast2-supporting-records-import.md) | The nightly **1.0** pull that keeps the Forecast2 lookup/dimension tables (`Forecast.Accounts`, `Classifications`, `Customers`, `ConsolidatedCustomers`, `Employ | worker/crons/toga2/forecast2/import_supporting_records.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker/schedules/cron.worker.infrastructure.json, worker/.ebextensions/009_setup_phpini.config, worker/crons/sync/netsuite/netsuite_customers.php, library/app/api/netsuite/rest.php, library/app/model/forecast2/customer.php, library/app/model/forecast2/consolidatedcustomer.php |
13
13
  | [NetSuite Sales Order Sales Rep Sourcing (Staples & ODP EDI orders)](features/netsuite-sales-order-sales-rep-sourcing.md) | How the **sales rep** on a NetSuite Sales Order is determined for the two 1.0 `worker` EDI order-creation integrations (Staples cXML and Compass/ODP EDI). | worker/crons/toga2/compass/workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php, worker/crons/sync/staples/sync_staples_cxml.php, test/@Mark/NetSuite/TRUE_80451_customer_salesrep_diag.php |
14
14
  | [NetSuite → TOGa Supply Per-Client Sync (thin wrappers)](features/netsuite-togasupply-per-client-sync.md) | Syncs NetSuite transactions (sales orders, purchase orders, invoices, item receipts, item fulfillments, inventory adjustments) into each TOGa Supply (2.0) clien | worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/sync_togasupply_canon.php, worker/crons/toga2/netsuite/sync_togasupply_elite.php, worker/schedules/cron.worker.sync.json, dbchanges2/_modules/netsuite/2026-04-01 - Parameters.sql, library/app/api/toga2.php, library/app/api/netsuite/rest.php, library/app/query.php, library/app/database.php, library/app/framework.php, library/app/systemmonitor/netsuiteintegration.php, dbchanges2/Client/2026-09-02b - NetsuiteSyncCursorRename.sql, test/@srija/Elite Testing/Service Requests/test_sync_togasupply_elite_section.php, test/@srija/Elite Testing/Service Requests/test_diagnose_togasupply_elite.php |
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-28
10
- owners: [dfranks, jcardinal, kyalamarthi]
9
+ updated: 2026-09-04
10
+ owners: [dfranks, jcardinal, kyalamarthi, ajean]
11
11
  files:
12
12
  - test/@dave/checker.php
13
13
  - worker2/Component/Forecast/SaleImport/SaleImport.php
@@ -32,6 +32,7 @@ files:
32
32
  - test/@dave/probe_profit_gap.php
33
33
  - worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php
34
34
  - worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_open_orders.php
35
+ - worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_opportunities.php
35
36
  - worker/crons/toga2/forecast2/import_open_orders.php
36
37
  - worker/schedules/cron.worker.infrastructure.json
37
38
  related:
@@ -373,6 +374,50 @@ have broken **every insert the cron makes**. It was caught only by rendering the
373
374
  stub harness before any run. **`php -l` cannot catch malformed SQL string assembly**; when you edit a
374
375
  query *builder*, print the built query and read it.
375
376
 
377
+ ### ⚠ The opportunities discrepancy-fix cron DELETES rows that carry a ClickUp link (2026-09-04)
378
+
379
+ **This is a live data-loss path — treat it as the highest-priority item in this doc.**
380
+ `worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_opportunities.php` (209 lines,
381
+ `0 4 * * *`, **active**) and the same code path in `tools/bin/forecast/fixer.php`
382
+ (`fixOpportunities()`) both build their delete set **without ever selecting or checking
383
+ `clickupTaskId`**.
384
+
385
+ **Blast radius, measured 2026-09-04: 746 of 26,766 in-window rows carried a ClickUp task id**, and
386
+ one row was already stranded at the `PENDING` sentinel. Deleting such a row **orphans the ClickUp
387
+ task** and, because the 2.0 webhook's atomic claim keys on `Forecast.Opportunities.clickupTaskId`,
388
+ the next NetSuite event for that opportunity finds no claim and **creates a duplicate task**. The
389
+ trigger does not require anything exotic: **a single truncated `listOpportunities()` page makes every
390
+ unreturned opportunity look stale**, so an API hiccup is enough to mass-delete live rows.
391
+ Claim semantics and the duplicate-creation consequence:
392
+ [NetSuite ↔ ClickUp Opportunity Sync](../../../../2.0/apps/worker2/features/netsuite-opportunity-sync.md).
393
+
394
+ Three further defects found in the same file:
395
+
396
+ 1. **⚠ `MAINTENANCE_MODE = true` gates LOGGING ONLY — it does NOT stop writes.** Its sibling
397
+ `periodic_forecast_discrepancy_fix_open_orders.php` uses the same-named constant as a genuine
398
+ **dry run**. So the reasonable assumption — "same constant, same meaning, safe to leave on while I
399
+ watch it" — is **wrong here, and it deletes**. This is a genuine trap, not a style inconsistency:
400
+ verify per file before relying on it.
401
+ 2. **The INSERT omits seven columns:** `forecastCategoryId`, `salesStageId`,
402
+ `percentToCloseStatusId`, `projectedProfit`, `leadSource`, `clickupTaskId`, and now
403
+ `endCustomerName`. Rows the cron recreates therefore come back stripped of all seven — the same
404
+ delete-and-reinsert failure shape as the `locationId` case above.
405
+ 3. **Empty `sql_mode` in prod turns omission #2 into silent corruption.** `projectedProfit` is
406
+ NOT NULL with **no default**, so instead of rejecting the INSERT, MySQL silently stores `0.00`.
407
+ **This is the root cause of the perpetual checker↔fixer churn** this doc has tracked for months:
408
+ the checker flags a profit delta, the fixer "repairs" the row, the cron zeroes it again the next
409
+ night.
410
+
411
+ **And the reconciler cannot compute `projectedProfit` at all.** It fetches **no line items** — zero
412
+ references to `itemList` or `costEstimate` anywhere in the file — while opportunity profit is
413
+ Σ(amount − costEstimate) over qualifying lines (the formula recorded above). So it cannot fix the
414
+ column it breaks, and there is no version of "just add `projectedProfit` to the INSERT" that works
415
+ without adding line-item fetching first.
416
+
417
+ **Agreed direction (2026-09-04): the reconciler should STOP INSERTing and report instead.** A
418
+ component that cannot compute a NOT-NULL column should not be authoring rows. Detail in
419
+ [the bidirectional opportunity-sync design](../../../../2.0/apps/worker2/features/netsuite-clickup-opportunity-bidirectional-design.md).
420
+
376
421
  ## Data model
377
422
 
378
423
  `Forecast.Sales`, `Forecast.OpenOrderItems` on the **core2** cluster
@@ -654,6 +699,21 @@ None — Forecast2 is a single shared dataset.
654
699
  sequence the rotation against whatever else reads that replica.
655
700
 
656
701
  ## Change history
702
+ - 2026-09-04 — **Investigation only; no code changed (a prototype fix was written and deliberately
703
+ reverted).** Recorded a **live data-loss path**: the nightly
704
+ `periodic_forecast_discrepancy_fix_opportunities.php` (`0 4 * * *`, active) and
705
+ `tools/bin/forecast/fixer.php::fixOpportunities()` build their delete set **without checking
706
+ `clickupTaskId`** — **746 of 26,766 in-window rows carried a task id** (one already stranded at
707
+ `PENDING`), so a delete **orphans the ClickUp task and causes duplicate task creation**, and a
708
+ single truncated `listOpportunities()` page is enough to make every unreturned opportunity look
709
+ stale. Three more defects in the same file: **`MAINTENANCE_MODE = true` gates logging only, NOT
710
+ writes** (unlike its `..._open_orders.php` sibling, where it *is* a dry run — a genuine trap); the
711
+ INSERT **omits** `forecastCategoryId`, `salesStageId`, `percentToCloseStatusId`, `projectedProfit`,
712
+ `leadSource`, `clickupTaskId` and `endCustomerName`; and because prod `sql_mode` is **empty**,
713
+ `projectedProfit` (NOT NULL, no default) silently lands as **`0.00`** — the **root cause of the
714
+ long-standing checker↔fixer churn**. Also established that the reconciler **cannot compute
715
+ `projectedProfit` at all** (it fetches no line items — zero `itemList`/`costEstimate` references),
716
+ so the agreed direction is that it **stops INSERTing and reports** instead. (ajean)
657
717
 
658
718
  - 2026-08-28 — **⚠ Security finding (not fixed):** the `tools` repo copies of these scripts —
659
719
  `tools/bin/forecast/fixer.php:1424` and `tools/bin/forecast/checker.php:678` — hardcode **live
@@ -7,7 +7,7 @@
7
7
  | [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, dbchanges2/Core/2026-08-27a - TransferOrderUuidSearchableIdentifier.sql, _underscore/Model/Core/Page.php, _underscore/Model/Core/Surface.php, _underscore/Model/Client/TrackingNumber.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql, dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql, dbchanges2/Client/2026-08-29 - ItemReceiptTransferOrderIdFieldPermission.sql, dbchanges2/Client/2026-08-29b - ItemReceiptItemTransferOrderItemIdFieldPermission.sql, dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql |
8
8
  | [Address Uniqueness Normalization (unit identifier + 5-digit ZIP comparison)](features/address-uniqueness-normalization.md) | When a business rule says *"only one X per physical address"*, comparing address rows field-for-field does **not** work: the same dwelling is spelled many diffe | _underscore/Model/Rate/Entitlement.php |
9
9
  | [Address Validation (carrier waterfall + validateAddress scripted endpoint)](features/address-validation.md) | `_Model_Client_Address::validateAddress` verifies a US address against a **carrier waterfall (USPS → FedEx → UPS)** and returns a single canonical, carrier-norm | _underscore/Model/Client/Address.php, _underscore/Component/Library/Carriers/Usps/Usps.php |
10
- | [_ApiRequest — JSON encode/decode & api-logging behavior](features/apirequest-json-content-type.md) | `_ApiRequest` is the 2.0 outbound HTTP client. | _underscore/ApiRequest.php |
10
+ | [_ApiRequest — JSON encode/decode & api-logging behavior](features/apirequest-json-content-type.md) | `_ApiRequest` is the 2.0 outbound HTTP client. | _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php, _underscore/Component/Api/Clickup/Clickup.php |
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 | _underscore/Model/Client/AssortmentTranslation.php, dbchanges2/Client/2026-06-26a - AssortmentTranslations.sql, dbchanges2/Core/2026-06-26a - AssortmentTranslationsRecord.sql, dbchanges2/Client/2026-06-26b - AssortmentTranslationsAcl.sql |
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 | _underscore/Query.php, worker2/Worker/Infrastructure/Database.php, worker2/Worker/Team/Transcripts.php |
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. | _underscore/Model.php, _underscore/Model/Client/SalesOrder.php, _underscore/Model/Client/TransferOrder.php |
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-08-26
10
- owners: ["jcardinal", "rgirish", "mhammontree"]
9
+ updated: 2026-09-04
10
+ owners: ["jcardinal", "rgirish", "mhammontree", "ajean"]
11
11
  files:
12
12
  - _underscore/_underscore.php
13
13
  - _underscore/Loader.php
@@ -31,7 +31,10 @@ Minimum PHP 8.1 (enforced in `_underscore.php`). The current architecture is
31
31
  **Critical rules:** framework classes use a leading-underscore prefix (`_Model_*`, `_Controller_*`,
32
32
  `_Worker_*`). Always use parameterized queries / the `_Db` layer — never interpolate input into SQL.
33
33
  Beware the **lazy-transaction gotcha**: writes issued outside an explicitly committed transaction
34
- can be silently dropped — confirm commit semantics before relying on a write.
34
+ can be silently dropped — confirm commit semantics before relying on a write. Two corollaries:
35
+ **never hold a DB transaction open across an external HTTP call**, and **an empty log table is not
36
+ proof a code path never ran** — uncommitted rows vanish, so absence of log rows means "no committed
37
+ write", not "no execution".
35
38
  The Surface presentation layer replaces `Page::meta()` but `meta()` must NOT be deleted until
36
39
  every page is cut over (per-surface, with a parity diff); config describes, PHP decides —
37
40
  business logic never moves into Surface config.
@@ -369,6 +372,18 @@ multi-file UI components (`.php`/`.html`/`.css`/`.js`) invoked as `<_ComponentNa
369
372
  pin is `Addresses.isValidated` and `Entitlements.serviceAddressId` both-or-neither. Relevant code:
370
373
  `_underscore/Model/Rate/Entitlement.php`, `api2/Component/Api/V2/V2.php::getFullModelData`.)
371
374
  - **`_Database::register()` auto-starts a lazy transaction (since Apr 2 2026, commit `fa7835ed`).** Any code that calls `register()` and then writes to that DB must call `_Database::transactionCommit()` before the request ends — otherwise MySQL silently rolls back all writes when the connection closes. Lazy transactions only materialise on the first write, so read-only callers are unaffected. See `_underscore/Database.php:48`. First discovered when Rate SAML user provisioning silently discarded all new user INSERTs (Jun 2026).
375
+ - **Never hold a DB transaction open across an external HTTP round-trip.** Beyond pinning a
376
+ connection for the duration of a third-party call, it means the log/telemetry rows you are
377
+ writing *about* that call are still uncommitted while it runs. Live instance found 2026-09-04:
378
+ `_Worker_Clickup_Opportunity::logged()` opens `DB_LOGS`, never commits, and holds the
379
+ transaction across every ClickUp/NetSuite call.
380
+ - **An empty log table is NOT proof that a code path never ran.** This is the diagnostic trap the
381
+ gotcha creates, and it has already produced one wrong conclusion in this knowledge base: a
382
+ zero-row `Logs.Api` census was read as "this integration has been dead since it shipped," when
383
+ the writes were simply never committed. Before concluding a path is dead from absent rows,
384
+ confirm the writer commits. Correct pattern:
385
+ `worker2/Worker/Netsuite/SalesOrder.php:441`. Case detail:
386
+ [netsuite-opportunity-sync](../worker2/features/netsuite-opportunity-sync.md).
372
387
  - **`_Model::load()` with a collapsed WHERE clause silently full-table-scans → OOM (guarded 2026-08-26, `Model.php`).** `_Model::search()` builds `SELECT <pk> FROM <table> [WHERE …] ORDER BY <pk>` and emits the WHERE **only if at least one search term survives**. `buildSqlFieldValue()` **drops** a term (returns false) when a set field's value is `null` and its type is a plain `FIELD_CHAR` — i.e. not one of the special-cased types (`AUTOINCREMENT`, `DATETIME_CREATED`, `DATETIME_UPDATED`, `SQL`, `STORAGE`, `CHAR_UUID`). If the only set search field collapses this way, the WHERE vanishes and `search()` selects the **whole table**, constructing one model per row → memory exhaustion on a large table. Because `load()` calls `search()` first and only then checks `count() == 1`, it **OOMs building the result array before it ever counts** — the empty-WHERE case never reaches the "not exactly one" branch. **`load()` means "find exactly one by criteria"; an empty WHERE is always a bug, never a full-table scan.** **Fix (commit `0b944a51`, `_production`):** `load()` now guards at the top — if no set search field yields a WHERE term (via `buildSqlFieldValue`), it `error_log`s and returns `false` (or throws when `throwExceptionIfNotFound`) instead of scanning. `search()` is unchanged, so intentional "list all rows" callers of `search()` are unaffected. **Triage:** a PHP OOM stack trace of `Model.php search() → __construct` that is *shallow* (a single search→construct, not deeply repeated) is a runaway result set from a collapsed WHERE, **not** recursion — confirm by checking whether the single set search field is a `FIELD_CHAR` whose value is null. (Root cause of a recurring prod OOM: `Logs.Issue` ref `2L` (#38), first seen 2026-08-03, 136 occurrences, trace `Model.php search()→__construct`.)
373
388
  - **PHP "Unclosed '{'" parse errors report a MISLEADING line number.** When a `.php` file loaded by the autoloader (`Loader.php`) has a dropped/unbalanced brace, PHP reports `Unclosed '{' on line N` where N is the **outermost `class X {` line** and fails at EOF — NOT at the true location of the missing `}`. Worse, because the file loads lazily via the SPL autoloader, the runtime trace points at the **caller** that triggered the autoload (e.g. a `new _Email()` call site), not the broken file. **Triage rule:** for an "Unclosed '{'" error, the real culprit is a missing `}` somewhere between the reported line and EOF of the file that failed to load — run `php -l <file>` (it reports the EOF line) and scan the whole file. **Merge-conflict resolutions are a common source of a single dropped brace** — review the entire merge, not just the one file the error appears to name. (First hit: production 500 EO-1, Jul 2026 — a `}` dropped from `_Email::send()` during merge `685e4a14` surfaced as a trace pointing at the `new _Email()` caller.)
374
389
 
@@ -6,10 +6,12 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-26
9
+ updated: 2026-09-04
10
10
  owners: ["jcardinal", "ajean"]
11
11
  files:
12
12
  - _underscore/ApiRequest.php
13
+ - _underscore/Model/Client/Logs/Api.php
14
+ - _underscore/Component/Api/Clickup/Clickup.php
13
15
  related:
14
16
  - ../../worker2/features/oneuptime-worker2-monitoring.md
15
17
  - ../../worker2/features/creating-worker-actions.md
@@ -90,10 +92,46 @@ case-insensitive scan of already-set headers, so a caller-supplied `Content-Type
90
92
  **hardcodes** its token in committed source — flagged for a rotate-and-move-to-config ticket; 11
91
93
  worker2 files use it). When adding a new component, decide deliberately: log and accept the header
92
94
  exposure, strip/redact the auth header before the call is logged, or `setLogging(false)`.
95
+ - **Multipart/file upload ALREADY WORKS — do not add an `ENCODE__MULTIPART` case (2026-09-04).**
96
+ The request-encoding switch (`ApiRequest.php:205-228`) has **no `default:` case**, so
97
+ `payloadEncoding: null` passes the payload through **untouched** and sets no `Content-Type`; `:236`
98
+ hands it straight to `CURLOPT_POSTFIELDS`, where **cURL itself** builds the multipart body and the
99
+ boundary. So the working recipe needs no framework change at all: pass an array such as
100
+ `['attachment' => new CURLFile($path, $mime, $name)]` with `payloadEncoding` left null, and cURL
101
+ **streams the file from disk** (memory stays flat regardless of file size). Add `setLogging(false)`
102
+ (you do not want file bytes in `Logs.Api`) and an `Expect: ''` header (suppresses cURL's
103
+ 100-continue round-trip).
104
+ **Why an `ENCODE__MULTIPART` case is the wrong fix:** `$payloadEncoding` drives **both** request
105
+ encoding **and** response decoding (`:358-372`). A multipart-**request** / JSON-**response** call
106
+ cannot be expressed in one enum value — the response would fall through to `default:` and come back
107
+ as a **raw string instead of a decoded object**, breaking the caller. `new _ApiRequest` appears at
108
+ **97 sites across 35 files** in `_underscore` + `worker2`, so this would be an expensive and buggy
109
+ change to shared core for no gain. (Independent `/cto` review: DISAGREE-WITH-ALTERNATIVE — the
110
+ alternative being exactly the pass-through recipe above.)
111
+ - **⚠ `_ApiRequest` FATALS on a non-string request payload — latent for all 97 callers
112
+ (found 2026-09-04, NOT fixed).** `ApiRequest.php:268` assigns
113
+ `$log->requestPayload = $this->requestPayload;` into a **`FIELD_CHAR`** column, and that value
114
+ reaches `_Database::escape()`. Hand it an **array** — which is exactly what the multipart recipe
115
+ above requires, since the array holds a `CURLFile` — and you get a **fatal error**, not a bad log
116
+ row. The failure lands in the *logging* path, so it looks nothing like a payload problem. One-line
117
+ guard: `is_string($this->requestPayload) ? $this->requestPayload : '[non-scalar payload omitted]'`.
118
+ Until that lands, any array payload **must** use `setLogging(false)`.
93
119
  - **`Logs.Api` is 1.4M+ rows and `source` is NOT indexed.** Any diagnostic query must be bounded by
94
120
  `dtStamp` (indexed) or it times out — `WHERE source = '…'` alone will not return.
95
121
 
96
122
  ## Change history
123
+ - 2026-09-04 — **Investigation only; no framework change made (deliberately).** Established that
124
+ `_ApiRequest` **already supports multipart/file upload** with no core change: the request switch
125
+ (`:205-228`) has no `default:` case, so `payloadEncoding: null` passes the payload through and
126
+ `:236` lets cURL build the multipart body + boundary — pass
127
+ `['attachment' => new CURLFile($path,$mime,$name)]` and it streams from disk with flat memory
128
+ (add `setLogging(false)` + `Expect: ''`). Recorded **why not to add an `ENCODE__MULTIPART` case**:
129
+ `$payloadEncoding` drives request encoding *and* response decoding (`:358-372`), so
130
+ multipart-request/JSON-response is inexpressible and the response would return a raw string; 97
131
+ `new _ApiRequest` sites across 35 files would be at risk (`/cto`: DISAGREE-WITH-ALTERNATIVE). Also
132
+ found a **latent fatal**: `:268` assigns `requestPayload` into a `FIELD_CHAR` column that reaches
133
+ `_Database::escape()`, so a **non-string payload is a fatal error, not a bad log row** — live for
134
+ all 97 callers the moment anyone passes an array. (ajean)
97
135
  - 2026-08-26 — Documented the **manual-insert column contract** (`isAuthRequest`, `instanceId`,
98
136
  `hostname` are NOT NULL with no defaults, and `_ApiRequest` sets them at `ApiRequest.php:260-264`),
99
137
  after a hand-rolled `logged()` helper in two worker2 handlers threw on every insert and silently
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-23
10
- owners: [dfranks]
9
+ updated: 2026-09-04
10
+ owners: [dfranks, ajean]
11
11
  files:
12
12
  - worker2/Component/Forecast/SaleImport/SaleImport.php
13
13
  - worker2/Component/Forecast/Db/Db.php
@@ -167,6 +167,20 @@ uses) and then writes the line — instead of the cron's silent skip. Failure se
167
167
  the job for retry.
168
168
  - `itemGroup` items carry no line revenue and are skipped.
169
169
 
170
+ **⚠ This makes the catch clause a hard constraint on the whole codebase (2026-09-04).** The engine
171
+ catches **only `RuntimeException`**, and the "infrastructure failures propagate" half of the contract
172
+ above works *only because* `_Component_Api_Netsuite::send()` throws a **plain `Exception`**. So:
173
+
174
+ - **Any new exception type raised on this path must extend `Exception` directly.** Give it a
175
+ `RuntimeException` parent and a **429 gets swallowed as "unmappable item"** — the line is skipped
176
+ and logged as bad data, the job reports success, and the sale is silently missing. This is why
177
+ `_Exception_Database` extends plain `Exception` too; see
178
+ [v2 request retry — scope](../../api2/features/v2-deadlock-retry.md).
179
+ - **One case is already misclassified today:** `_Component_Api_Netsuite::fetchRecord()` **does** throw
180
+ `RuntimeException` on a 200-with-non-object body, so a **malformed** NetSuite response reaches this
181
+ catch and is booked as an unmappable item rather than an infrastructure failure. See
182
+ [NetSuite REST client](./netsuite-rest-client.md).
183
+
170
184
  ## JournalEntry import (the fifth transaction type)
171
185
  JournalEntry extends the same engine to capture **GL revenue/cost adjustments posted
172
186
  directly as journal entries**. It is **structurally different from the four sale types** — a
@@ -589,6 +603,13 @@ success from a `Forecast.Sales` row alone.
589
603
  - The cron's sign handling is not portable here — see Sign convention.
590
604
 
591
605
  ## Change history
606
+ - 2026-09-04 — Recorded (no code change) that the `RuntimeException`-only catch in the item self-heal
607
+ is a **constraint on new code**: because "infrastructure failure" is signalled by
608
+ `_Component_Api_Netsuite::send()` throwing a **plain `Exception`**, any new exception type on this
609
+ path must extend `Exception` directly or **API 429s get swallowed as "unmappable item"** (line
610
+ skipped, job green, sale silently missing). Also noted one case already misclassified:
611
+ `fetchRecord()` throws `RuntimeException` on a 200-with-non-object body, so a malformed NetSuite
612
+ response is treated as bad data. (ajean)
592
613
  - 2026-07-23 — **Broadened the AMQ event-capture blind spot beyond inline line-edits** (TRUE-80262
593
614
  planning; no code shipped, dfranks). Recorded that **bulk/mass updates and CSV imports** fire no UE
594
615
  (CSV runs server SuiteScript only when "Run Server SuiteScript and Trigger Workflows" is on — OFF by
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-27
10
- owners: ["dfranks", "jcardinal", "bala", "kyalamarthi"]
9
+ updated: 2026-09-04
10
+ owners: ["dfranks", "jcardinal", "bala", "kyalamarthi", "ajean"]
11
11
  files:
12
12
  - _underscore/Component/Api/Netsuite/Netsuite.php
13
13
  - _underscore/ApiRequest.php
@@ -176,6 +176,16 @@ Surfaced building the NYCHH asset-tag backfill (see
176
176
 
177
177
  ## NetSuite transaction status sourcing (2.0) — and the 2026.2 REST `status.id` change
178
178
 
179
+ > **`fetchRecord()` throws `RuntimeException` on a 200-with-non-object body — so a MALFORMED
180
+ > NetSuite response is already misclassified as bad data (confirmed 2026-09-04).** The distinction
181
+ > matters to every caller that separates "NetSuite gave me something I can't map" (a data problem, do
182
+ > not retry) from "NetSuite is unhealthy" (infrastructure, retry). A 200 whose body isn't an object
183
+ > is the second thing wearing the costume of the first. Concretely,
184
+ > `worker2/Component/Forecast/SaleImport/SaleImport.php` catches **only `RuntimeException`** and will
185
+ > swallow it as an unmappable item — see
186
+ > [Forecast.Sales import](./forecast-sale-import.md) and the retry-scope note in
187
+ > [v2 deadlock retry](../../api2/features/v2-deadlock-retry.md).
188
+
179
189
  **`fetchRecord()` returns the REST record UNTOUCHED — there is no central status
180
190
  normalization in the shared client.** `fetchRecord()` (`Netsuite.php` ~L215-223) does
181
191
  `send('GET', route)`, json-decodes, and hands the raw record back; it never reads or maps
@@ -235,6 +245,15 @@ Two consequences, one good and one expensive:
235
245
  Note that `Authorization` headers are persisted in plaintext by that same auto-logging path — see
236
246
  [`_ApiRequest` JSON/logging behavior](./apirequest-json-content-type.md).
237
247
 
248
+ **⚠ `authenticate()` persists the signed OAuth JWT to `Logs.Api` on every token refresh
249
+ (found 2026-09-04, NOT fixed).** The `:100` construction noted above is the auth call, and it never
250
+ calls `setLogging(false)` — so the signed **`client_assertion` JWT** is written into the logs DB
251
+ every time the token is refreshed. That is a credential-bearing artifact sitting in a table with
252
+ 1.4M+ rows and broad read access. **`setLogging(false)` on that one call is a pure win with no
253
+ visibility cost:** the auth request carries no business data, and an auth failure still surfaces as
254
+ a thrown exception from `send()`/`execute()`. Unlike the general "logging is expensive" question,
255
+ there is no tradeoff to weigh here.
256
+
238
257
  ## Gotchas / known issues
239
258
 
240
259
  - **No `Location` header / wrong header accessor.** `_ApiRequest` has `responseHeaders` (raw
@@ -253,6 +272,13 @@ Note that `Authorization` headers are persisted in plaintext by that same auto-l
253
272
 
254
273
  ## Change history
255
274
 
275
+ - 2026-09-04 — Recorded two findings from a read-only investigation (no code changed).
276
+ **(1)** `authenticate()` never calls `setLogging(false)`, so the signed OAuth **`client_assertion`
277
+ JWT** is persisted to `Logs.Api` on **every token refresh**; `setLogging(false)` there costs no
278
+ diagnostic visibility (auth failures still throw) and should be added. **(2)** `fetchRecord()`
279
+ throws **`RuntimeException`** on a 200-with-non-object body, which means a **malformed NetSuite
280
+ response is already misclassified as bad data rather than infrastructure failure** — significant
281
+ because `SaleImport` catches only `RuntimeException` and will book it as an unmappable item. (ajean)
256
282
  - 2026-08-27 — **CORRECTED the Logging section: NetSuite REST calls ARE logged to `Logs.Api`, always.**
257
283
  The previous claim (`send()` calls `setLogging(false)`, bodies not captured) is false — there are
258
284
  **zero** `setLogging` occurrences in `_underscore/Component/Api/Netsuite/Netsuite.php`, and all three
@@ -6,8 +6,8 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: draft
9
- updated: 2026-09-01
10
- owners: [jcardinal, bala]
9
+ updated: 2026-09-04
10
+ owners: [jcardinal, bala, ajean]
11
11
  files:
12
12
  - api2/Controller/Index.php
13
13
  - api2/Component/Api/V2/V2.php
@@ -154,6 +154,31 @@ every branch. See
154
154
  [ENVIRONMENT decides the `_underscore` branch](./environment-variable-drives-underscore-branch.md)
155
155
  and [V2 request logging](./request-logging.md).
156
156
 
157
+ ## ⚠ Scope: this protocol is MySQL-statement-scoped and has NO HTTP dimension (2026-09-04)
158
+
159
+ Two retry mechanisms now sit next to each other in 2.0 and are easy to confuse. Keep them apart:
160
+
161
+ | Concern | Mechanism | Retries what |
162
+ |---|---|---|
163
+ | MySQL deadlock / lock-wait / unique race | this doc — `wasDeadlock()`, `wasRetriableUniqueRace()`, `ERRNO_DEADLOCK` 1213, `ERRNO_LOCK_WAIT_TIMEOUT` 1205, `ERRNO_DUPLICATE_ENTRY` 1062, request-level replay | a **SQL statement / request** against the DB |
164
+ | Outbound HTTP 429 / transient API failure | **`_ApiRequest::setAutoRetry()`** | an **HTTP call** |
165
+
166
+ **Do not reach for this protocol for an API 429.** Nothing here inspects HTTP status; the errno
167
+ consts are MySQL error numbers and `wasDeadlock()` reads `_Database::$lastErrorNumber`. Use
168
+ `setAutoRetry()` (and mind its flat-delay / retry-every-non-2xx behavior — see
169
+ [`_ApiRequest`](../../_underscore/features/apirequest-json-content-type.md)).
170
+
171
+ **Two consequences of the typed exception worth knowing:**
172
+
173
+ - **`_Exception_Database` extends `Exception` deliberately — not `RuntimeException`.** That is a
174
+ design choice, not an oversight: callers that catch `RuntimeException` to mean "bad data, skip this
175
+ row" must **not** accidentally swallow a DB failure. It also means any *new* exception type added
176
+ near this code must extend `Exception` directly, or existing catch blocks change meaning — a live
177
+ case in [Forecast.Sales import](../../_underscore/features/forecast-sale-import.md).
178
+ - **Every SQL failure in worker2 is now `_Exception_Database`.** So any code that compares
179
+ `get_class($e)` or matches on DB error **text** changes behaviour under this protocol. Audit for
180
+ those before assuming the change is transparent.
181
+
157
182
  ## Gotchas / known issues
158
183
 
159
184
  - Detection is built on the **MySQL errno** (1213/1205), not on matching the response text —
@@ -164,6 +189,14 @@ and [V2 request logging](./request-logging.md).
164
189
  not a substitute for removing the deadlock at its source.
165
190
 
166
191
  ## Change history
192
+ - 2026-09-04 — Added a **scope** section (no code change): this retriable protocol is
193
+ **MySQL-statement-scoped and has no HTTP dimension** — for outbound API 429s use
194
+ `_ApiRequest::setAutoRetry()`, not `wasDeadlock()`/`wasRetriableUniqueRace()`. Recorded that
195
+ `_Exception_Database` extends **plain `Exception`** deliberately (so a `RuntimeException`
196
+ "bad-data, skip" catch cannot swallow a DB failure), and that because **every** worker2 SQL failure
197
+ is now `_Exception_Database`, any code comparing `get_class()` or DB error text changes behaviour.
198
+ Surfaced while separating DB retry from API retry during the NetSuite↔ClickUp opportunity
199
+ investigation. (ajean)
167
200
  - 2026-09-01 — Recorded the **deployment reach** of this work's `_underscore` primitives: commit
168
201
  `6355d0f0` (`ERRNO_DUPLICATE_ENTRY`, `$lastErrorNumber`, `$retriableUniqueRace`,
169
202
  `wasRetriableUniqueRace()`) is on **`_production` and `_sandbox-client` only** — **absent from
@@ -6,7 +6,7 @@ project: Database Changes
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-08-18
9
+ updated: 2026-09-04
10
10
  owners: [jcardinal, mhammontree, bala, ajean]
11
11
  files:
12
12
  - Core/
@@ -96,6 +96,7 @@ capital, rest lower-case (e.g. `Compass`, `Compasscanada`).
96
96
  |---|---|---|
97
97
  | `Core/` | `Core` (shared platform DB) | single DB |
98
98
  | `Logs/` | `Logs` (framework-level logs, not tenant-scoped) | single DB |
99
+ | `Forecast/` | `Forecast` (the core2 forecast DB) | single DB |
99
100
  | `Client/` | **every** `Client_<Tenant>` database | fan-out to all active clients |
100
101
  | `Client_<Name>/` | the **one** `Client_<Name>` DB only | single tenant |
101
102
  | `Logs_Client/` | **every** `Logs_<Tenant>` database | fan-out to all client-log DBs |
@@ -347,7 +348,7 @@ its own header.)
347
348
 
348
349
  ## Adding a new change (the rules)
349
350
 
350
- 1. Pick the right folder for the target DB: `Core/`, `Logs/`, the shared `Client/` (every
351
+ 1. Pick the right folder for the target DB: `Core/`, `Logs/`, `Forecast/`, the shared `Client/` (every
351
352
  tenant), a specific `Client_<Name>/`, `Logs_Client/` (every tenant log), or a module under
352
353
  `_modules/<module>/`.
353
354
  2. Name the file `YYYY-MM-DD<letter> - <ShortDescription>.sql` using **today's date** and a
@@ -11,7 +11,7 @@
11
11
  | [ClickUp Connectivity Watchdog](features/clickup-connectivity-watchdog.md) | A cron watchdog that emails when the ClickUp integration looks disconnected during business hours. | worker2/Worker/Clickup/Health.php, worker2/Database/ClickupHealthWatchdog.sql |
12
12
  | [ClickUp Design Sprint Automation (Final Design Outcome)](features/clickup-design-sprint-automation.md) | `_Worker_Clickup_Design` is meant to drive the design-sprint workflow in ClickUp via the API, replacing a set of native ClickUp automations. | worker2/Worker/Clickup/Design.php, worker2/Worker/Clickup.php, worker2/Controller/ClickupDesignTest.php, _underscore/Component/Api/Clickup/Clickup.php |
13
13
  | [ClickUp GitHub-tab Auto-linking & Ticket-id Branch Naming](features/clickup-github-autolink.md) | How ClickUp surfaces branches/PRs/commits in a ticket's **GitHub tab**, and the branch / PR-title naming convention that triggers it. | |
14
- | [ClickUp Project & Opportunity Multi-List Routing](features/clickup-project-routing.md) | Routes ClickUp tasks into the correct **secondary multi-list memberships** based on their custom-field values, via the `clickup` webhook. | worker2/Worker/Clickup/Project.php, worker2/Worker/Clickup.php |
14
+ | [ClickUp Project & Opportunity Multi-List Routing](features/clickup-project-routing.md) | Routes ClickUp tasks into the correct **secondary multi-list memberships** based on their custom-field values, via the `clickup` webhook. | worker2/Worker/Clickup/Project.php, worker2/Worker/Clickup.php, worker2/Worker/Clickup/Project.md |
15
15
  | [ClickUp Rich-Text Custom Fields via Quill Delta (API)](features/clickup-richtext-api.md) | ClickUp custom text fields (type `text` and long-text) support rich formatting only through a **Quill Delta** written to the undocumented `value_richtext` key o | test/@dave/clickup_md2delta.js, .claude/skills/plan-ticket/scripts/clickup.js |
16
16
  | [ClickUp Subtask Activity → Parent Opportunity/Epic Comments](features/clickup-subtask-activity.md) | Surfaces **subtask** progress, completion, and discussion on the top-level **Opportunity** or **Epic** it rolls up to, so a deal/project owner sees activity whe | worker2/Worker/Clickup/Subtask.php, worker2/Worker/Clickup.php, dbchanges2/Team/2026-07-14a - Add ClickupSubtaskActivity ledger.sql |
17
17
  | [ClickUp Task Description Fluffer (Talos KB Investigation)](features/clickup-task-fluffer-talos.md) | `_Worker_Clickup_Fluffer` "fluffs" a ClickUp task by sending its name + existing description to the **Talos `dev-core` KB-investigation agent** and writing the | worker2/Worker/Clickup/Fluffer.php, worker2/Worker/Clickup.php, worker2/Config/production.ini, worker2/Config/beta.ini, worker2/Config/dev-rohan-mac.ini |
@@ -26,9 +26,10 @@
26
26
  | [Etilize Catalog Item Import & Refresh](features/etilize-catalog-item-import.md) | Client-generic catalog onboarding from an S3 CSV plus an Etilize re-pull. | worker2/Worker/Etilize/Items.php |
27
27
  | [Etilize Item Translation Import](features/etilize-item-translation-import.md) | The abstract worker class `_Worker_Etilize_ItemTranslations` imports **non-English** item text from Etilize into the client's `ItemTranslations` table. | worker2/Worker/Etilize/ItemTranslations.php |
28
28
  | [Monitoring Framework (Orchestrator + Child Monitors)](features/monitoring-framework.md) | A unified, DB-driven monitoring framework for business-critical data flows (Compass POs, Prudential asset imports, AIG closed claims, …). | worker2/Worker/Monitor.php, worker2/Worker/Monitors/, worker2/Worker/Monitors/RateEntitlement.php, worker2/Worker/Notification/Email.php, worker2/Worker/Rate.php, dbchanges2/Core/2026-05-21 - Monitors.sql, dbchanges2/Core/2026-06-29a - Rate Entitlement Contract Monitor.sql |
29
+ | [Bidirectional NetSuite ↔ Forecast ↔ ClickUp opportunity sync — AGREED DESIGN, not built](features/netsuite-clickup-opportunity-bidirectional-design.md) | **Status: designed and agreed, NOT built. | worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Clickup/Opportunity.php, worker2/Worker/Clickup/Project.php, worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_opportunities.php |
29
30
  | [NetSuite Integrations Monitor (Monitor/Operations/NetsuiteIntegrations)](features/netsuite-integrations-monitor.md) | `_Worker_Monitor_Operations::NetsuiteIntegrations()` is a cross-client health check that detects **stuck NetSuite ↔ 2.0 integrations**. | worker2/Worker/Monitor/Operations.php, dbchanges2/Core/2026-08-10a - Netsuite Integrations Monitor.sql, _underscore/Database.php, _underscore/Query.php |
30
31
  | [ClickUp Opportunity Client vs End-Customer Labels (Stakeholders / End Customer)](features/netsuite-opportunity-client-labels.md) | The NetSuite→ClickUp opportunity task carries **two** customer labels, not one (TRUE-81010, PR #142): - **Stakeholders** = the **client** — the consolidated par | worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Netsuite/OpportunityStakeholderDigest.php, worker2/Worker/Netsuite/OpportunityStakeholderBackfill.php, worker2/Worker/Netsuite/OpportunityEndCustomerBackfill.php, dbchanges2/Forecast/2026-09-01a - Add endCustomerName to Opportunities.sql, tools/mvc/clickup/aliases/get.php, tools/mvc/clickup/aliases/post.php, tools/_/app/clickup/aliases.php |
31
- | [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 |
32
+ | [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, worker/crons/toga2/forecast2/import_opportunities.php, library/app/model/forecast2/opportunity.php, worker2/Worker/Clickup/Project.md |
32
33
  | [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, worker2/Worker/Netsuite/OpenOrderLocationBackfill.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, worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_open_orders.php, test/@dave/probe_ooi_location_gap.php, library/app/api/netsuite/rest.php, dbchanges2/Core/2026-08-27a - Insert - Netsuite Location SyncAll CronJob.sql |
33
34
  | [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 |
34
35
  | [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/OpportunityEndCustomerBackfill.php, worker2/Worker/Netsuite/Customer.php, library/app/model/forecast2/customer.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
@@ -51,6 +52,7 @@
51
52
  | [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 |
52
53
  | [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 |
53
54
  | [1.0 Worker Fleet Role-Assignment Monitor (Monitor/Fleet/RoleAssignment)](features/worker-fleet-role-assignment-monitor.md) | `_Worker_Monitor_Fleet::RoleAssignment()` is a **2.0 worker2 cron that watches the 1.0 `worker` fleet from the outside**. | worker2/Worker/Monitor/Fleet.php, worker2/_.php, dbchanges2/Core/2026-08-24 - Worker Role Assignment Monitor.sql, worker/ebs/cron.worker.php, worker/crons/worker/worker_heartbeat.php, library/app/worker.php |
55
+ | [Rotating the ClickUp API credential (and migrating to the devteam service account)](workflows/clickup-credential-rotation.md) | How to rotate the ClickUp API token, or move ClickUp integration traffic onto the dedicated `devteam@togatech.com` service account, **without killing the live w | _underscore/Component/Api/Clickup/Clickup.php, worker2/Worker/Team/Sprint.php, worker2/Worker/Clickup/Project.php, library/app/systemmonitor/500error.php, library/app/systemmonitor/netsuiteintegration.php, library/app/systemmonitor/emailspam.php |
54
56
  | [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 |
55
57
  | [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/_.php, library/_.php, test/@dave/nsq2.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, _underscore/ApiRequest.php, dbchanges2/Logs_Client/2026-04-08_BLANK_CLIENT_LOGS_DATABASE.SQL |
56
58
  | [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 |