toga-ai 1.0.392 → 1.0.394

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,8 +6,8 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-30
10
- owners: ["dfranks"]
9
+ updated: 2026-07-21
10
+ owners: ["dfranks", "jcardinal"]
11
11
  files:
12
12
  - library/app/api/netsuite/rest.php
13
13
  - library/ssl/netsuite_ec_key.pem
@@ -343,6 +343,13 @@ numbers. Budget hours for multi-year runs and launch them under `nohup`/`tmux`.
343
343
  arrow functions (`fn() =>`), typed properties (`public int $x`), `??=`, `match()`, numeric
344
344
  separators (`1_000`). Lint with `C:\xampp7\php\php.exe -l` before deploying — `C:\xampp8\php`
345
345
  (PHP 8.0) is for running probes on the laptop only, **not** for compat checking.
346
+ - **⚠ Blast radius: a SINGLE PHP 7.4+ token here is a FULL outage, not a localized bug.** On
347
+ 2026-06-10 one `static fn(...)` left in `library/app/api/netsuite/rest.php` **parse-errored
348
+ `App_Api_Netsuite_Rest` on class load** — since the parse failure happens at require time, it
349
+ took down **every** consumer of the class at once: **all** 5-minute REST sync crons crashed and
350
+ stayed down until the token was reverted. There is no partial degradation — a banned token
351
+ anywhere in this file crashes the whole NetSuite REST tier. Lint before every deploy; treat a
352
+ 7.4+ token in `rest.php` as a production-down defect, not a style nit.
346
353
  - Field/relationship availability varies by NetSuite account **and** record type — probe the live
347
354
  account before assuming a column exists.
348
355
  - **`IS NOT NULL` on a custom `transactionline` column inside an aggregate query is a ~15× planner
@@ -364,6 +371,11 @@ numbers. Budget hours for multi-year runs and launch them under `nohup`/`tmux`.
364
371
 
365
372
  ## Change history
366
373
 
374
+ - 2026-07-21 — **Recorded the "one PHP 7.4+ token = total outage" blast-radius lesson** on the PHP
375
+ 7.2 compat gotcha (folded in from a retired project-local CLAUDE.md). On 2026-06-10 a single
376
+ `static fn(...)` in `rest.php` parse-errored `App_Api_Netsuite_Rest` on class load and crashed ALL
377
+ 5-minute REST sync crons until reverted — a banned token here is production-down, not localized.
378
+ (jcardinal)
367
379
  - 2026-07-07 — **Added the `systemnote` field-change forensics section and qualified the custom-column
368
380
  perf rule** (Forecast2 SALES profit-drift forensics). `systemnote` filtered by `field`+`"date"` is
369
381
  ~1s vs ~279s by `recordid` alone; it carries `context` (`UIF`), `name` (acting user), `role`,
@@ -5,7 +5,7 @@
5
5
  | [Worker (1.0 Framework) Architecture](architecture.md) | `worker` is the legacy (**1.0** `App_` framework) **background-job tier**. | worker/index.php, worker/_/app/framework.php, worker/crons/, worker/schedules/, worker/ebs/cron.worker.php, worker/.ebextensions/035_cron.worker.config |
6
6
  | [Compass MA Sales Order Exception Report](features/compass-ma-sales-order-exception-report.md) | A worker cron that emails operations the "Compass Refresh Exception Report" — Compass `MA%` sales orders whose corresponding Office Depot (ODP) sales order has | worker/crons/toga2/compass/workflow/7_generate_ma_sales_order_exception_report.php |
7
7
  | [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/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php |
8
- | [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, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.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 |
8
+ | [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, 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 |
9
9
  | [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/schedules/cron.worker.sync.json, dbchanges2/_modules/netsuite/2026-04-01 - Parameters.sql, library/app/api/netsuite/rest.php, library/app/systemmonitor/netsuiteintegration.php |
10
10
  | [OneUptime external uptime monitoring for 1.0 workers](features/oneuptime-worker-uptime-monitoring.md) | Every 1.0 worker box self-reports its liveness to an external OneUptime monitor once per minute by curl-POSTing to a per-worker "Incoming Request" heartbeat URL | library/app/worker.php, worker/crons/worker/worker_heartbeat.php |
11
11
  | [Prudential: Send Shipments for the Day report (daily cron)](features/send-shipments-for-the-day.md) | Daily cron (9:00 PM) that emails Prudential and Dell stakeholders an Excel report of all devices shipped that day, including tracking number, serial number, emp | worker/crons/notifications/reports/send_shipments_for_the_day.php |
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-14
10
- owners: [dfranks]
9
+ updated: 2026-07-21
10
+ owners: [dfranks, jcardinal]
11
11
  files:
12
12
  - test/@dave/checker.php
13
13
  - worker2/Component/Forecast/SaleImport/SaleImport.php
@@ -16,6 +16,9 @@ files:
16
16
  - test/@dave/fixer.php
17
17
  - test/@dave/analyze_netsuite_forecast_diff.php
18
18
  - test/@dave/trueup_sales.php
19
+ - test/@dave/reconcile_drift_2023plus.php
20
+ - test/@dave/probe_invoice_gap_2026.php
21
+ - test/@dave/probe_creditmemo_gap_detail.php
19
22
  - test/@dave/trueup_open_orders.php
20
23
  - test/@dave/loop_trueup_open_orders.php
21
24
  - test/@dave/trueup_opportunities.php
@@ -98,6 +101,15 @@ by reconciling a chosen tranDate range directly against NetSuite.
98
101
  NS_ONLY (missing from FC), FC_ONLY (stale/extra), DRIFT (value differs). Read-only.
99
102
  - `trueup_sales.php --from --to [--chunk-days N] [--prod] [--dry-run]` — makes `Forecast.Sales`
100
103
  match NetSuite for a tranDate range (insert/update/delete per line).
104
+ - `reconcile_drift_2023plus.php` — **transaction-level** (NOT line-level) reconciler for a
105
+ **known, small, discrepant set** of transactions. Distinct from the line-level trueup tools: for
106
+ **only the flagged transactions** it **DELETEs all lines then re-INSERTs them from REST**, writes
107
+ a **pre-image CSV backup** first, and is **dry-run by default** (`--commit` to write). Built to
108
+ close the SOAP-era double-line bug (below): it took the 2023 gap from **+$2.27M to +$31.8K**.
109
+ **Decision rule — which reconciler:** a **known small discrepant set** → `reconcile_drift_2023plus`
110
+ (transaction-level delete+reinsert); an **unknown full-window** drift → `trueup_sales`
111
+ (line-level insert/update/delete). **Diagnostic ladder to identify the set:**
112
+ `probe_sales_gap_direct` → `probe_invoice_gap_2026` → `probe_creditmemo_gap_detail`.
101
113
  - `trueup_open_orders.php --from= --to= [--prod] [--dry-run] [--verbose] [--quiet] [--by-lastmodified]`
102
114
  — same for `Forecast.OpenOrderItems` (currently-open SOs whose tranDate falls in range).
103
115
  - **`--by-lastmodified`** windows **both** passes on `lastmodifieddate` instead of `tranDate`:
@@ -303,6 +315,17 @@ None — Forecast2 is a single shared dataset.
303
315
 
304
316
  ## Gotchas / known issues
305
317
 
318
+ - **SOAP-era double-line bug (2023) — lines inserted TWICE under consecutive-but-different line
319
+ numbers, evading the dup check.** The SOAP-era importer **ran twice for some invoice batches** and
320
+ inserted each invoice's lines **twice** under consecutive-but-*different* `lineNumber`s — so the
321
+ `(txnId, lineNumber)` duplicate check never caught it (the second copy had a different line
322
+ number). **Fingerprint:** Forecast revenue is **exactly 2× NetSuite**, **transaction counts
323
+ match** (it's line duplication, not extra transactions), and **FC line rows > NS line count**.
324
+ Affected **3 months in 2023** (transaction dates **May 1 / Jul 2 / Dec 7**). Fixed with
325
+ `reconcile_drift_2023plus.php` (transaction-level delete-all-lines-then-reinsert-from-REST), which
326
+ closed the 2023 gap from **+$2.27M to +$31.8K**. When a window shows exact-2× revenue with matching
327
+ txn counts and more FC lines than NS, suspect this pattern and use the transaction-level reconciler,
328
+ not the line-level trueup.
306
329
  - **CROSS-TYPE CONTAMINATION: once JEs share `Forecast.Sales`, any FC Sales read missing a type scope
307
330
  treats JE rows as rogue sales.** Discovered live (TRUE-79862): the SALES fix **deleted 74 JE rows
308
331
  (8 JEs) as "stale sales"** because `findSalesDiscrepancies`'s FC query had **no type filter**, so JE
@@ -549,6 +572,17 @@ None — Forecast2 is a single shared dataset.
549
572
 
550
573
  ## Change history
551
574
 
575
+ - 2026-07-21 — **Documented `reconcile_drift_2023plus.php` + the SOAP-era double-line bug (2023)**
576
+ (folded in from a retired project-local CLAUDE.md). The SOAP-era importer ran twice for some
577
+ invoice batches and inserted each invoice's lines twice under consecutive-but-different line
578
+ numbers, evading the `(txnId, lineNumber)` dup check — fingerprint = FC revenue exactly 2× NS,
579
+ matching txn counts, FC line rows > NS lines; affected 3 months in 2023 (txn dates May 1 / Jul 2 /
580
+ Dec 7). `reconcile_drift_2023plus.php` reconciles a **known small set** at the **transaction level**
581
+ (DELETE all lines then re-INSERT from REST for only flagged txns; pre-image CSV backup; dry-run by
582
+ default, `--commit` to write), closing the 2023 gap from +$2.27M to +$31.8K. Recorded the decision
583
+ rule (known small set → `reconcile_drift_2023plus`; unknown full window → `trueup_sales`) and the
584
+ diagnostic ladder (`probe_sales_gap_direct` → `probe_invoice_gap_2026` →
585
+ `probe_creditmemo_gap_detail`). (jcardinal)
552
586
  - 2026-07-14 — **`fixer.php` now resolves `customerId` on JE lines, mirroring the importer +
553
587
  recorded the manual all-time backfill** (TRUE-80129, dfranks). `fixJournalEntries()` (the
554
588
  hand-maintained mirror of `buildJournalEntryRows`) previously hard-coded `customerId = NULL`;
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-26
10
- owners: ["dfranks"]
9
+ updated: 2026-07-21
10
+ owners: ["dfranks", "jcardinal"]
11
11
  files:
12
12
  - _underscore/Component/Api/Netsuite/Netsuite.php
13
13
  related:
@@ -31,6 +31,40 @@ SuiteQL escaping rule — that callers in other repos depend on.
31
31
  > (`\api\_Component_Api_Netsuite`) for its own use. Editing the api2 copy has **no effect** on the
32
32
  > worker2/webhook path — change the `_underscore` one.
33
33
 
34
+ ## Authentication & config
35
+
36
+ - **OAuth 2.0 certificate-signed JWT client assertion.** The adapter authenticates by signing a
37
+ JWT client assertion with a certificate registered on the NetSuite integration record — no
38
+ username/password, no stored bearer. Signing algorithm is **ES256 preferred** (EC private key),
39
+ with **RS256 as fallback**. The access token is **cached and auto-refreshed ~60s before expiry**,
40
+ so callers never manage token lifecycle.
41
+ - **All credentials come from the `[netsuite]` ini section — nothing is hardcoded.** Keys:
42
+ - `host` — the NetSuite REST host.
43
+ - `clientId` — the integration record's client id.
44
+ - `certificateId` — the integration record's certificate id (the JWT **`kid`**).
45
+ - `privateKeyPath` — filesystem path to the signing private key.
46
+ The per-developer block lives in `worker2/Config/dev-<name>-laptop.ini`. **Document only the key
47
+ names/locations — never paste the values.** (See the 1.0 `netsuite-suiteql-api-reference.md`
48
+ *Authentication* section for the sibling `App_Api_Netsuite_Rest` client, which reads the same
49
+ `[netsuite]` section via `_Config::netsuite()` in the 2.0 context.)
50
+
51
+ ### Public API surface
52
+
53
+ - **`send($method, $route, $payload, $headers, $throwExceptionsOnError)`** — the general REST call.
54
+ Verb + route + JSON payload + optional extra headers; `$throwExceptionsOnError` toggles whether a
55
+ non-2xx raises or is returned. (Record writes go through `createRecord`/`send('PATCH', …)` above;
56
+ reads through `fetchRecord`/`send('GET', …)`.)
57
+ - **`RECORD_*` constant catalog** — canonical route constants so callers never hardcode record
58
+ paths: `SALES_ORDER`, `INVOICE`, `CASH_SALE`, `CASH_REFUND`, `CREDIT_MEMO`, `OPPORTUNITY`, `ITEM`,
59
+ `INVENTORY_ITEM`, `CUSTOMER`, `EMPLOYEE`, `VENDOR`, `PURCHASE_ORDER`.
60
+ - **`QUERY_SUITEQL`** — the SuiteQL query route; **`METADATA_CATALOG`** — the metadata/catalog route
61
+ (record & field metadata discovery).
62
+ - **`?expandSubResources=true`** — append to a record GET to inline sub-resources (sublists) in one
63
+ call rather than issuing follow-up requests.
64
+ - **Response normalization rule:** the underlying transport may hand back an already-decoded object
65
+ **or** a raw JSON string, so normalize every response with `is_string($x) ? json_decode($x) : $x`
66
+ before consuming it.
67
+
34
68
  ## Record writes — REST has no SOAP `baseRef->internalId`
35
69
 
36
70
  ### Create — `createRecord(string $route, array $payload): string`
@@ -121,6 +155,13 @@ doc.)
121
155
 
122
156
  ## Change history
123
157
 
158
+ - 2026-07-21 — **Documented the auth mechanism, config-key locations, `send()` signature, and the
159
+ `RECORD_*`/query/metadata constant catalog** (folded in from a retired project-local
160
+ `worker2/netsuite/CLAUDE.md`). Auth = OAuth 2.0 certificate-signed JWT client assertion
161
+ (ES256 preferred / RS256 fallback), token cached + refreshed ~60s before expiry; all credentials
162
+ from the `[netsuite]` ini section (`host`/`clientId`/`certificateId`=`kid`/`privateKeyPath`),
163
+ per-dev block in `worker2/Config/dev-<name>-laptop.ini`. Recorded `?expandSubResources=true` and
164
+ the `is_string($x) ? json_decode($x) : $x` response-normalization rule. (jcardinal)
124
165
  - 2026-06-26 — **`fetchRecord` (generic NS REST GET + decode) moved onto this class** from
125
166
  `_Component_Forecast_Db`, beside `createRecord`; 7 callers reprefixed (SaleImport ×2,
126
167
  Opportunity ×3, SalesOrder ×2), git-grep clean + runtime-verified. Recorded the
@@ -6,7 +6,7 @@ project: Database Changes
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-07-20
9
+ updated: 2026-07-21
10
10
  owners: [jcardinal, mhammontree, bala]
11
11
  files:
12
12
  - Core/
@@ -173,6 +173,13 @@ its own header.)
173
173
  the relevant clients list `<module>` in their `_modules.txt`.
174
174
  5. Never edit or re-date an already-applied file — add a new dated file instead. Never put new
175
175
  work in a `HISTORIC` folder.
176
+ 6. **Never seed a `uuid` column with MySQL `UUID()`.** The 2.0 standard requires a fully-random
177
+ **v4** UUID; `UUID()` is time/MAC-based (v1) and violates it. Since a migration can't call
178
+ `_String::generateUuid()`, **pre-generate a v4 UUID literal** (e.g. a v4 generator) and
179
+ hardcode the literal string in the `VALUES` clause. One earlier migration
180
+ (`Core/2026-05-08 … CronJobs_WorkerCleanup_insert`) used `UUID()` — that is a known violation,
181
+ not a precedent to copy. First applied correctly in
182
+ `Core/2026-07-21b - Insert - SprintLockScheduled CronJob.sql`.
176
183
 
177
184
  ## Bulk data loads — batch, and stage large sets in a temp table
178
185
 
@@ -285,3 +292,10 @@ platform-wide infrastructure, not a single application. It has no code dependenc
285
292
  folder names mirror the DB families (`Core`, `Logs`, `Client_<Tenant>`, `Logs_<Tenant>`)
286
293
  defined in `2.0/apps/_underscore/architecture.md`, and its change files create/alter the
287
294
  tables that `_Model_*` classes map to.
295
+
296
+ ## Change history
297
+ - 2026-07-21 — Added rule #6 to *Adding a new change*: never seed a `uuid` column with MySQL
298
+ `UUID()` (time/MAC-based v1, violates the 2.0 v4-UUID standard); pre-generate a v4 UUID
299
+ literal and hardcode it in `VALUES`. `Core/2026-05-08 … CronJobs_WorkerCleanup_insert` is a
300
+ known violation, not a precedent; first applied correctly in
301
+ `Core/2026-07-21b - Insert - SprintLockScheduled CronJob.sql`. (jcardinal)
@@ -28,7 +28,7 @@
28
28
  | [Talos (TOGa IQ) Meeting-Notes Integration & Token Auto-Refresh (consumer)](features/talos-meeting-notes-integration.md) | How a **dev tool / agent consumes Talos (TOGa IQ)** to query the team meeting-notes corpus programmatically. | .claude/skills/plan-ticket/scripts/talos.js |
29
29
  | [Talos Pricing Automation (worker2 Cron — AWS Actuals, Calibration, Monthly Report)](features/talos-pricing-automation.md) | The worker2 half of the **Talos Pricing Platform** (see the talos `pricing-cogs-model` and tools `talos-pricing-ui` docs for the other halves). | worker2/Worker/Talos/Pricing.php, worker2/Database/TalosPricingCrons.sql |
30
30
  | [Talos Transcript Ingestion Pipeline (worker2 → AWS Bedrock KBs)](features/talos-transcript-ingestion.md) | `_Worker_Team_Transcripts` runs a fully automated, cron-driven pipeline that ingests raw Teams transcripts into the **Talos / TOGa IQ** AWS Bedrock knowledge ba | worker2/Worker/Team/Transcripts.php, worker2/bin/sync-knowledge-bases.php, worker2/Config/production.ini, worker2/Database/TeamsTranscriptExports.sql, dbchanges2/Team/2026-06-30a, dbchanges2/Team/2026-06-30b, dbchanges2/Team/2026-06-30c, dbchanges2/Team/2026-06-30d, dbchanges2/Team/2026-06-30e, dbchanges2/Core/2026-06-30a, dbchanges2/Core/2026-07-02a, dbchanges2/Team/2026-07-02a, dbchanges2/Team/2026-07-08a, dbchanges2/Team/2026-07-09a, dbchanges2/Team/2026-07-10a |
31
- | [Team Sprint Management & Reporting](features/team-sprint-management.md) | `_Worker_Team_Sprint` (file `Worker/Team/Sprint.php`) is the engine behind TOGA's internal **development-sprint process and reporting**. | worker2/Worker/Team/Sprint.php |
31
+ | [Team Sprint Management & Reporting](features/team-sprint-management.md) | `_Worker_Team_Sprint` (file `Worker/Team/Sprint.php`) is the engine behind TOGA's internal **development-sprint process and reporting**. | worker2/Worker/Team/Sprint.php, dbchanges2/Core/CronJobs (SprintLockScheduled seed) |
32
32
  | [Teams Meeting Transcript Export](features/teams-transcript-export.md) | > **SUPERSEDED (2026-07-09) — the S3-staging model below is history.** `Export` is now a thin > **GRAPH-DIRECT** cron poller: it no longer archives raw VTT to ` | worker2/Worker/Team/Transcripts.php, worker2/Config/production.ini, worker2/Database/TeamsTranscriptExports.sql, dbchanges2/Core/2026-06-18a - Teams Transcript Export schedule.sql |
33
33
  | [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 |
34
34
  | [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 |
@@ -111,11 +111,29 @@ No Lambda/routing changes — just create the file:
111
111
  2. Insert a `Core.CronJobs` row:
112
112
  ```sql
113
113
  INSERT INTO Core.CronJobs (uuid, isActive, name, schedule, maxExecutionTime, action, parameters)
114
- VALUES (UUID(), 1, 'Daily Report Generation', '0 6 * * *', 300, 'Reports/Daily/Generate', NULL);
114
+ VALUES ('xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx', 1, 'Daily Report Generation', '0 6 * * *', 300, 'Reports/Daily/Generate', NULL);
115
+ -- uuid MUST be a pre-generated v4 UUID literal, NOT MySQL UUID() (v1) — see dbchanges2 architecture.md rule #6
115
116
  ```
116
117
  `schedule` is evaluated in **Central time**; `maxExecutionTime` is the watchdog timeout.
117
118
  3. CronScheduler fires it at the next matching minute — no code wiring needed.
118
119
 
120
+ Adding a recurring job is **pure data**: inserting the `Core.CronJobs` row (uuid, isActive,
121
+ name, schedule, maxExecutionTime, action, parameters) is all that's needed. The CronScheduler
122
+ Lambda runs every minute, matches active schedules via `croniter`, and enqueues a `WorkerJobs`
123
+ job. There is **no Lambda/code change** beyond the row — but the referenced `action` must exist
124
+ in the deployed worker code, so ship the code deploy and the dbchanges2 cron-row migration
125
+ **together**.
126
+
127
+ > **Biweekly / non-cron schedules — run often, self-gate in PHP.** Cron (5-field) can express
128
+ > "every Wednesday" but **not** "every *other* Wednesday" (or any period cron can't name).
129
+ > Don't try to encode it in the schedule. Instead schedule the job at the coarser recurring
130
+ > interval it fits (e.g. `0 10 * * 3`) and have the action **self-gate against a date/state
131
+ > table** at the top of the method — run the real work only when the gate says today qualifies,
132
+ > otherwise return a skip message (captured to `Core.WorkerJobs.output`). Proven by
133
+ > `Team/Sprint/SprintLockScheduled`, which fires every Wednesday but only runs `SprintLock()`
134
+ > when a `Sprints` row ends yesterday — see
135
+ > [team-sprint-management](./team-sprint-management.md).
136
+
119
137
  ## Test any action directly
120
138
 
121
139
  ```bash
@@ -175,6 +193,12 @@ be reattempted.
175
193
  commit-before-SQS transaction pattern that the worker relies on.
176
194
 
177
195
  ## Change history
196
+ - 2026-07-21 — Documented that adding a recurring job is **pure data** (a `Core.CronJobs` row;
197
+ CronScheduler Lambda matches via croniter every minute, no code wiring beyond the row — but
198
+ the action must be deployed), and the **biweekly self-gating pattern**: for schedules cron
199
+ can't express (e.g. every-other-Wednesday), run at a coarser cron interval and gate in PHP
200
+ against a date/state table, returning a skip otherwise. Proven by
201
+ `Team/Sprint/SprintLockScheduled`. (jcardinal)
178
202
  - 2026-07-21 — `WorkerJobs.failureReason` column renamed to **`output`** and repurposed to store
179
203
  successful execution results as well as failure text; success now writes the composed
180
204
  "Successfully Executed" message (return value `json_encode`d for arrays/objects). (jcardinal)
@@ -6,10 +6,11 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-24
9
+ updated: 2026-07-21
10
10
  owners: ["jcardinal"]
11
11
  files:
12
12
  - worker2/Worker/Team/Sprint.php
13
+ - dbchanges2/Core/CronJobs (SprintLockScheduled seed)
13
14
  related:
14
15
  - ../architecture.md
15
16
  - ./creating-worker-actions.md
@@ -64,6 +65,10 @@ A sprint runs ~14 days. Across the cycle these actions fire (scheduled as crons)
64
65
  2. **~9:00 AM** — `SprintEnd` captures and scores the just-finished sprint, emails reports,
65
66
  and (optionally) sends release notes.
66
67
  3. **~9:45 AM (after launch ceremony)** — `SprintLock` freezes the new sprint's plan.
68
+ In production this is now invoked via the scheduled self-gating wrapper
69
+ `SprintLockScheduled` (see below), not by a cron that targets `SprintLock` directly —
70
+ because the launch day is **every other Wednesday** and cron cannot express a biweekly
71
+ schedule.
67
72
  4. **Daily during the sprint** — `SprintDaily` emails leadership a progress dashboard.
68
73
 
69
74
  Most methods accept an optional `$sprint` number; when omitted they resolve the relevant
@@ -89,6 +94,21 @@ alert. With `$generateSprintLockReport`, emits an Excel lock report of committed
89
94
  unplanned/stretch points and tasks. When `$allowUnplannedTasks` is false, tasks appearing
90
95
  after lock with an UNPLANNED work type are flagged.
91
96
 
97
+ ### `SprintLockScheduled(): string`
98
+ A thin **scheduled wrapper around `SprintLock()`** that solves the biweekly-schedule problem.
99
+ The launch/lock ceremony runs every *other* Wednesday, but cron has no biweekly expression, so
100
+ its `CronJobs` row fires **every** Wednesday (`0 10 * * 3`, 10:00 AM Central) and this method
101
+ **self-gates in PHP** against the `Sprints` table: it queries for a sprint whose `dateEnd` =
102
+ **yesterday**. If one exists, today is a new-sprint start day → it calls `self::SprintLock()`.
103
+ Otherwise it returns a skip message (captured to `Core.WorkerJobs.output`) and does nothing.
104
+ `SprintLock()` itself is unchanged — this only decides *whether* to run it today. Action path
105
+ `Team/Sprint/SprintLockScheduled` → `_Worker_Team_Sprint::SprintLockScheduled`.
106
+
107
+ > **Deploy coupling.** The worker2 code deploy and the dbchanges2 migration that seeds the
108
+ > `Core.CronJobs` row must be **released together** — the cron row references the
109
+ > `Team/Sprint/SprintLockScheduled` action, which must exist in the deployed code or the
110
+ > scheduled job fails when it fires.
111
+
92
112
  ### `SprintDaily($sprint = null, …)`
93
113
  In-sprint **leadership dashboard**, read-only with respect to metrics. Optionally re-captures
94
114
  ClickUp data, then builds a multi-sheet workbook:
@@ -210,6 +230,13 @@ backfills `workTypeAtLock` the first time a task is seen).
210
230
 
211
231
  ## Change history
212
232
 
233
+ - 2026-07-21 — Added `SprintLockScheduled()` action + a `Core.CronJobs` seed
234
+ (`0 10 * * 3`, every Wednesday 10 AM Central, action `Team/Sprint/SprintLockScheduled`,
235
+ maxExecutionTime 600) to run **Sprint Lock automatically at the start of each biweekly
236
+ sprint**. Cron can't express "every other Wednesday", so the job runs every Wednesday and
237
+ self-gates in PHP: it calls the unchanged `SprintLock()` only when a `Sprints` row has
238
+ `dateEnd = yesterday` (i.e. today starts a new sprint), else returns a skip. Worker code and
239
+ the dbchanges2 cron-row migration must deploy together. (jcardinal)
213
240
  - 2026-06-24 — Fixed a duplicate-key crash (`Tasks.sprintId_clickupIdentifier`) in the
214
241
  ClickUp→DB sync: ClickUp pagination can return the same task on multiple pages, and the
215
242
  loop was overloading one map (`$lookupTaskByClickUpId`) for both update-vs-insert and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.392",
3
+ "version": "1.0.394",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",