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.
- package/knowledge/1.0/apps/webhook/INDEX.md +1 -1
- package/knowledge/1.0/apps/webhook/features/netsuite-clickup-opportunity-sync.md +50 -15
- package/knowledge/1.0/apps/worker/INDEX.md +1 -1
- package/knowledge/1.0/apps/worker/features/forecast2-netsuite-reconciliation.md +62 -2
- package/knowledge/2.0/apps/_underscore/INDEX.md +1 -1
- package/knowledge/2.0/apps/_underscore/architecture.md +18 -3
- package/knowledge/2.0/apps/_underscore/features/apirequest-json-content-type.md +39 -1
- package/knowledge/2.0/apps/_underscore/features/forecast-sale-import.md +23 -2
- package/knowledge/2.0/apps/_underscore/features/netsuite-rest-client.md +28 -2
- package/knowledge/2.0/apps/api2/features/v2-deadlock-retry.md +35 -2
- package/knowledge/2.0/apps/dbchanges2/architecture.md +3 -2
- package/knowledge/2.0/apps/worker2/INDEX.md +4 -2
- package/knowledge/2.0/apps/worker2/features/clickup-project-routing.md +85 -3
- package/knowledge/2.0/apps/worker2/features/netsuite-clickup-opportunity-bidirectional-design.md +155 -0
- package/knowledge/2.0/apps/worker2/features/netsuite-opportunity-sync.md +147 -25
- package/knowledge/2.0/apps/worker2/workflows/clickup-credential-rotation.md +142 -0
- package/knowledge/INDEX.md +1 -1
- package/package.json +1 -1
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
|
|
3
3
|
| Doc | Summary | Files |
|
|
4
4
|
|-----|---------|-------|
|
|
5
|
-
| [NetSuite → ClickUp Opportunity Sync](features/netsuite-clickup-opportunity-sync.md) |
|
|
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-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
88
|
-
`OPPORTUNITY_NUMBER ← customerNumber` /
|
|
89
|
-
**dormant 2.0 port**
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
**no** `maybeCreateClickupTask()` method anywhere
|
|
93
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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 |
|