toga-ai 1.0.424 → 1.0.426

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-21
9
+ updated: 2026-07-23
10
10
  owners: [dfranks, jcardinal]
11
11
  files:
12
12
  - test/@dave/checker.php
@@ -303,6 +303,36 @@ by reconciling a chosen tranDate range directly against NetSuite.
303
303
  the sale-import doc). The salesRep dimension itself is the JE line custom column `custcol_sales_rep_line`
304
304
  (wired under TRUE-79862).
305
305
 
306
+ - **Delete-safety is a targeted BY-ID RE-FETCH from NetSuite, NOT a `deletedrecord` system-log
307
+ lookup — mirror this in any new reconciler.** The fixer never blind-deletes a Forecast row just
308
+ because a windowed pull didn't return it. Its delete path (`fixer.php` Sales path ~773-891, and
309
+ `checker.php`) works by **re-fetching each discrepant transaction by its internal id** from
310
+ NetSuite via the local `fetchSalesBulk()` shim (`WHERE transaction IN (...)` SuiteQL, same shape
311
+ as the legacy `listSales()`): it upserts the lines NetSuite returns for that id and **deletes only
312
+ the FC lines NetSuite no longer returns for that specific id**. If NS returns **nothing** for the
313
+ id, the txn is gone → its FC rows are deleted. This by-id re-fetch is the concrete implementation
314
+ of the team's **"no blind auto-delete"** rule — it guards against a transient window/read miss
315
+ masquerading as a deletion (a `lastmodifieddate` window or a paged full-window scan can transiently
316
+ omit a row; a per-id GET/IN-list re-confirms it). A NetSuite **`deletedrecord` system-log gate is
317
+ NOT existing behavior** and should not be invented as the delete confirmation — the authoritative
318
+ signal is "the by-id re-fetch returns no lines."
319
+ - **Why an independent tranDate-range reconciliation is MANDATORY, not just a nicety — NetSuite event
320
+ capture cannot be guaranteed by any configuration.** Beyond the sublist line-field inline-edit blind
321
+ spot (below), a broader class of NetSuite changes fires **no** User Event SuiteScript at all, so the
322
+ AMQ enqueuer never runs, no webhook is posted, and `lastmodifieddate` is frequently **not** bumped:
323
+ - **Bulk / mass updates and CSV imports** — a CSV import only runs server SuiteScript (and workflows)
324
+ when **"Run Server SuiteScript and Trigger Workflows"** is enabled, which is **OFF by default**.
325
+ - **Changes made BY another script or workflow** — a UserEvent script **cannot be triggered by
326
+ another UserEvent script or a workflow**, so a server-side edit never re-fires the enqueuer UE.
327
+
328
+ Consequence: such edits drift `Forecast.Sales` (and any webhook-synced NetSuite data) **silently and
329
+ invisibly to BOTH** the real-time webhook path **and** a `lastmodifieddate`-windowed pull cron —
330
+ strictly broader than the already-known inline sublist line-field case. Durable implication: because
331
+ event capture is not guaranteeable, **correctness requires an independent reconciliation that reads
332
+ NetSuite by `tranDate` range and matches on internal id** (exactly what `checker`/`fixer` do) — this
333
+ is the load-bearing reason the reconciliation backstop exists, not an optimization. (Oracle docs:
334
+ server scripting on CSV import `section_4676525683`; how UE scripts are executed `section_1512409310`.)
335
+
306
336
  ## Data model
307
337
 
308
338
  `Forecast.Sales`, `Forecast.OpenOrderItems` on the **core2** cluster
@@ -572,6 +602,19 @@ None — Forecast2 is a single shared dataset.
572
602
 
573
603
  ## Change history
574
604
 
605
+ - 2026-07-23 — **Recorded WHY the reconciliation backstop is mandatory + the by-id re-fetch
606
+ delete-safety mechanism** (TRUE-80262 planning; no code shipped, dfranks). Documented the broader
607
+ NetSuite event-capture blind spot beyond the inline sublist edit: **bulk/mass updates and CSV
608
+ imports fire no UE** (CSV runs server SuiteScript only when "Run Server SuiteScript and Trigger
609
+ Workflows" is on — OFF by default), and a **UE cannot be triggered by another UE/workflow**, so
610
+ script/workflow-driven edits drift `Forecast.Sales` invisibly to BOTH the webhook path and a
611
+ `lastmodifieddate`-windowed cron (and often don't bump `lastmodifieddate`). Durable implication:
612
+ event capture can't be guaranteed by any NetSuite config, so an independent `tranDate`-range
613
+ reconciliation matched on internal id is required for correctness (Oracle docs `section_4676525683`
614
+ / `section_1512409310`). Also documented the fixer/checker **delete-safety = targeted by-id re-fetch**
615
+ (`fetchSalesBulk` shim; delete FC lines NS no longer returns for that id) as the concrete "no blind
616
+ auto-delete" implementation — a `deletedrecord` system-log gate is NOT existing behavior and must not
617
+ be invented by a future reconciler. (dfranks)
575
618
  - 2026-07-21 — **Documented `reconcile_drift_2023plus.php` + the SOAP-era double-line bug (2023)**
576
619
  (folded in from a retired project-local CLAUDE.md). The SOAP-era importer ran twice for some
577
620
  invoice batches and inserted each invoice's lines twice under consecutive-but-different line
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-14
9
+ updated: 2026-07-23
10
10
  owners: [dfranks]
11
11
  files:
12
12
  - worker2/Component/Forecast/SaleImport/SaleImport.php
@@ -360,6 +360,21 @@ evidence: two invoices whose ONLY change that day was a cost line-edit had **zer
360
360
  entries, while 8 other cost-edited invoices that also had normal create/edit activity **did** fire
361
361
  and synced. This is recurring/multi-user (~12/day; 35 `MCOSTESTIMATE` edits in 3 days by 9 users).
362
362
 
363
+ **Broader than the inline line-edit — a whole CLASS of NetSuite changes fires no UserEvent, so no
364
+ webhook is posted (and `lastmodifieddate` is often not bumped):**
365
+ - **Bulk imports / mass updates** and **CSV imports** — a CSV import runs server SuiteScript (and
366
+ workflows) **only** when **"Run Server SuiteScript and Trigger Workflows"** is enabled, which is
367
+ **OFF by default**; so the AMQ enqueuer UE never runs for a bulk/CSV load.
368
+ - **Changes made BY another script or workflow** — a **UserEvent script cannot be triggered by
369
+ another UserEvent script or by a workflow**, so a server-side edit never re-fires the enqueuer.
370
+
371
+ So event capture **cannot be guaranteed by any NetSuite configuration** — these edits drift
372
+ `Forecast.Sales` (and any webhook-synced NetSuite data) **silently and invisibly to BOTH** the
373
+ real-time webhook **and** a `lastmodifieddate`-windowed pull cron. This is the durable reason the
374
+ independent tranDate-range reconciliation (`checker`/`fixer`, see the reconciliation doc) is
375
+ **mandatory, not optional**. (Oracle docs: CSV server scripting `section_4676525683`; how UE scripts
376
+ are executed `section_1512409310`.)
377
+
363
378
  **Corollary gotcha: `lastmodifieddate` is NOT a reliable change signal for line-cost edits** — a
364
379
  line-field inline edit leaves it untouched, so **both** the webhook pipeline **and** any
365
380
  `lastmodifieddate`-windowed `list*()`/cron sync miss it entirely. **Only reconciliation**
@@ -574,6 +589,14 @@ success from a `Forecast.Sales` row alone.
574
589
  - The cron's sign handling is not portable here — see Sign convention.
575
590
 
576
591
  ## Change history
592
+ - 2026-07-23 — **Broadened the AMQ event-capture blind spot beyond inline line-edits** (TRUE-80262
593
+ planning; no code shipped, dfranks). Recorded that **bulk/mass updates and CSV imports** fire no UE
594
+ (CSV runs server SuiteScript only when "Run Server SuiteScript and Trigger Workflows" is on — OFF by
595
+ default) and that a **UE cannot be triggered by another UE or a workflow** — so script/workflow/bulk
596
+ edits post no webhook and often don't bump `lastmodifieddate`, drifting `Forecast.Sales` invisibly to
597
+ both the webhook path and a `lastmodifieddate`-windowed cron. Durable implication: NetSuite event
598
+ capture is not guaranteeable by any config, making the independent tranDate-range reconciliation
599
+ mandatory (Oracle docs `section_4676525683` / `section_1512409310`). (dfranks)
577
600
  - 2026-07-14 — **JE lines now resolve `customerId` (was hard-coded null) + corrected the
578
601
  engine's repo location to worker2** (TRUE-80129, dfranks). `buildJournalEntryRows` reads
579
602
  `JE_LINE_CUSTOMER_FIELD = 'entity'` — a **native** JE-line reference field (not a `custcol_*`,
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-29
10
- owners: [mhammontree]
9
+ updated: 2026-07-23
10
+ owners: [mhammontree, dfranks]
11
11
  files:
12
12
  - worker2/Worker/Monitor.php
13
13
  - worker2/Worker/Monitors/
@@ -54,6 +54,35 @@ orchestrator) · runtime config in the `Core.Monitors` table (no redeploy).
54
54
  Reused with **no changes**: `Core.CronJobs`, `Core.WorkerJobs`, the `WorkerCronScheduler`
55
55
  Lambda, the EB worker tier + SQS delivery (see [worker2 architecture](../architecture.md)).
56
56
 
57
+ ## Where monitor / data-quality results belong (design placement)
58
+
59
+ worker2 has **two** monitoring patterns and neither writes to the central **`Logs` Issues/Events**
60
+ tables. That is deliberate — **`Logs.Issues`/`Logs.Events` are strictly for application
61
+ errors/exceptions** that escalate to ClickUp or email (business-routed by `IssueEmailAddresses`
62
+ presence; see the escalation-cron work). **Periodic health-check, integration-health, and
63
+ data-quality RESULTS do NOT go there** — they belong in the Monitor framework:
64
+
65
+ - **Pattern A — internal DB-driven state machine** (this doc): a `Core.Monitors` row +
66
+ `_Worker_Monitor::Run(monitorId)` (`worker2/Worker/Monitor.php`) invokes a child `phpClass::Run()`
67
+ that returns `{isOk, message}`; the orchestrator manages ok/alert state, `consecutiveOkCount` /
68
+ `requiredConsecutiveOks` anti-flap, and sends alert/reminder/recovery mail via
69
+ `_Worker_Notification_Email::Send`, persisting state back to `Monitors`.
70
+ - **Pattern B — "dumb reporter, smart monitor"** (see
71
+ [OneUptime push-metric monitors](./oneuptime-worker2-monitoring.md)): a child measures one metric
72
+ and POSTs a JSON body to a OneUptime incoming-request (heartbeat) monitor; the **threshold/alerting
73
+ lives in OneUptime** (tunable without a deploy). This is the right home for integration-health /
74
+ data-quality checks.
75
+
76
+ So when deciding where a recurring check/reconciliation **result** goes, the answer is the Monitor
77
+ framework (Pattern B / OneUptime for integration-health and data-quality), **not** Issues/Events.
78
+ Key Pattern-B idioms (durable): the worker decides the threshold and emits a **string token**
79
+ OneUptime matches (`"alarm":"HIGH"` → offline/incident, because OneUptime can only string-match a
80
+ pushed body, not compare numbers); on a data-source failure POST an explicit **`status:error`** so
81
+ OneUptime distinguishes "metric high" from "checker is blind"; the `_ApiRequest` push uses
82
+ `setLogging(false)` + `setThrowExceptionsOnFailure(false)` so a failed ping never fails the job or
83
+ depends on the Logs DB; the heartbeat URL is a **push credential — never log it**; and the
84
+ human-readable result is the return string persisted in `WorkerJobs.output`.
85
+
57
86
  ## How it works
58
87
 
59
88
  One `Core.CronJobs` row per monitor (`action = 'Monitor/Run'`, `parameters =
@@ -237,6 +266,14 @@ clients' data flows (Compass, Prudential, AIG, Rate, …) but live as separate c
237
266
  HTML email + dashboard deep-links · anti-flap on the alarm side.
238
267
 
239
268
  ## Change history
269
+ - 2026-07-23 — **Recorded the design-placement decision: monitor / data-quality RESULTS belong in the
270
+ Monitor framework (Pattern B / OneUptime for integration-health & data-quality), NOT in the central
271
+ `Logs` Issues/Events tables** (which are strictly for escalating application errors/exceptions).
272
+ Summarized both patterns side by side and the durable Pattern-B idioms (worker decides the threshold
273
+ and emits a string token OneUptime matches; explicit `status:error` on data-source failure; the push
274
+ is `setLogging(false)`+`setThrowExceptionsOnFailure(false)` so a failed ping never fails the job; the
275
+ heartbeat URL is a push credential — never logged; result string persisted in `WorkerJobs.output`).
276
+ TRUE-80262 planning; no code shipped. (dfranks)
240
277
  - 2026-06-29 — Built the first child monitor, `_Worker_Monitors_RateEntitlement` (TRUE-79129) + change-set `2026-06-29a`; added the log-scan worked example, the `_underscore` outbound-API-log detection pattern, and confirmed child details (orchestrator never calls `initialize()` so children self-register non-Core connections; no commit on read-only `Run()`; heredoc cannot interpolate `self::CONST`). Re-flagged that `Core.Monitors` is still local-only — the new change-set hard-fails where the table is absent. (mhammontree)
241
278
  - 2026-06-10 — Documented the v1.0 monitoring framework (orchestrator, `Core.Monitors` table, recovery-side anti-flap state machine, child contract). First child monitor + staging/prod migration still pending. (mhammontree)
242
279
 
@@ -8,5 +8,5 @@
8
8
  | [Rate SAML SSO](features/saml-sso.md) | 2.0 | Rate uses Azure AD as its IdP (`login.rate.com`). | _underscore/Model/Rate/ClientAuthentication.php, saml/Controller/Index.php, toga2-view/src/hooks/useAuthenticationFlow.ts |
9
9
  | [Service Card Entitlement Display](features/service-card-entitlements.md) | 2.0 | Rate's home and services pages display one service card per purchased entitlement. | src/components/ServiceCard/ServiceCard.tsx, src/components/ServiceCard/index.ts, src/hooks/useBundleServices.ts, src/hooks/useActiveServices.ts, src/pages/Home/api/homeApi.ts, src/pages/Home/view/HomePage.tsx, src/pages/Home/viewModels/useHomePageViewModel.ts, src/pages/Services/view/ServicesPage.tsx, src/pages/Services/viewModels/useServicePageViewModel.ts, src/api/serviceAddressApi.ts, src/api/apiErrors.ts |
10
10
  | [Rate Service-Purchase Confirmation Emails (Tech / Warranty)](features/service-purchase-emails.md) | 2.0 | When a Rate customer purchases a service, a confirmation email is sent. | _underscore/Model/Rate/Entitlement.php, worker2/Worker/Notification/EmailTemplate.php, dbchanges2/Client_Rate/2026-06-30a - Rate purchase email templates.sql |
11
- | [Rate Whole Home Warranty Per-Address Purchase Guard](features/whole-home-warranty-purchase-guard.md) | 2.0 | > **⚠ DEPLOYING beta→production, NOT YET PROD-VERIFIED (as of 2026-07-23).** TRUE-79533 is > beta-verified (PM Paulina tested the WH purchase flow on beta) and | _underscore/Model/Rate/Entitlement.php, _underscore/Model/Client/Entitlement.php, _underscore/Model/Client/Address.php, dbchanges2/Client/2026-07-22a - EntitlementServiceAddressId.sql, dbchanges2/Core/2026-07-22a - EntitlementServiceAddressIdField.sql, dbchanges2/Client/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql, test/@Mark/Rate/verify_wholehome_per_address_guard.php |
11
+ | [Rate Whole Home Warranty Per-Address Purchase Guard](features/whole-home-warranty-purchase-guard.md) | 2.0 | > **⚠ DEPLOYING beta→production, NOT YET PROD-VERIFIED (as of 2026-07-23).** TRUE-79533 is > beta-verified (PM Paulina tested the WH purchase flow on beta) and | _underscore/Model/Rate/Entitlement.php, _underscore/Model/Client/Entitlement.php, _underscore/Model/Client/Address.php, dbchanges2/Client/2026-07-22a - EntitlementServiceAddressId.sql, dbchanges2/Core/2026-07-22a - EntitlementServiceAddressIdField.sql, dbchanges2/Client_Rate/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql, test/@Mark/Rate/verify_wholehome_per_address_guard.php |
12
12
  | [Rate](profile.md) | 2.0 | Rate is a mortgage/lending client. | |
@@ -14,7 +14,7 @@ files:
14
14
  - _underscore/Model/Client/Address.php
15
15
  - dbchanges2/Client/2026-07-22a - EntitlementServiceAddressId.sql
16
16
  - dbchanges2/Core/2026-07-22a - EntitlementServiceAddressIdField.sql
17
- - dbchanges2/Client/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql
17
+ - dbchanges2/Client_Rate/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql
18
18
  - test/@Mark/Rate/verify_wholehome_per_address_guard.php
19
19
  related:
20
20
  - clients/rate/profile.md
@@ -32,11 +32,12 @@ related:
32
32
  > **shared-interceptor merge hazard** gotcha and the **dangling-pin** / **unguarded prod
33
33
  > interceptor** gotchas before merging to `_production`.
34
34
  >
35
- > **🚨 LAUNCH BLOCKER (added 2026-07-23, tcox) — the standard `serviceAddressId` field has NO
36
- > READ ACL grant.** The custom→standard redesign shipped the column + `Core.RecordFields` row
37
- > but **not** the `AclFieldPermissions` read grant, so `GET /v2/entitlements?fields=serviceAddressId`
38
- > returns **403 `EZ-2`** for roles 1/2/4. Prod does **not** launch clean without the new
39
- > `Client/2026-07-23a` field-permission migration below — see
35
+ > **🚨 LAUNCH BLOCKER — RESOLVED (migration built 2026-07-23, mhammontree; found 2026-07-23,
36
+ > tcox) — the standard `serviceAddressId` field had NO READ ACL grant.** The custom→standard
37
+ > redesign shipped the column + `Core.RecordFields` row but **not** the `AclFieldPermissions`
38
+ > read grant, so `GET /v2/entitlements?fields=serviceAddressId` returned **403 `EZ-2`**. The fix
39
+ > now ships as **`dbchanges2/Client_Rate/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql`**
40
+ > (committed `b74f42a` on TRUE-79533) — see
40
41
  > **[Launch blocker: standard field needs a READ ACL grant](#launch-blocker-true-79533--the-standard-field-needs-a-read-acl-grant)**.
41
42
 
42
43
  ## Summary
@@ -144,7 +145,7 @@ during backend review (Jeff). This is the single biggest change since the beta b
144
145
 
145
146
  **The custom→standard redesign shipped the column and the `Core.RecordFields` row but NOT the
146
147
  `AclFieldPermissions` READ grant.** Probe-verified on **beta 2026-07-23**:
147
- `GET /v2/entitlements?fields=serviceAddressId` returns **403 `EZ-2`** for roles 1, 2, and 4
148
+ `GET /v2/entitlements?fields=serviceAddressId` returns **403 `EZ-2`** (no read ACL row)
148
149
  (Entitlements has `Core.Records.aclDatabase = 'CLIENT'`, so the grant lives in each client DB).
149
150
  Without the grant, **production launches with the exact `EZ-2` failure beta hit** — the field is
150
151
  registered but unreadable, so the service cards cannot fetch the pin.
@@ -157,16 +158,20 @@ field is authorized through **`AclFieldPermissions`**, keyed by the **`Core.Reco
157
158
  because the `CustomRecordFields` row for `c_serviceAddressId` was deleted in the standard-field
158
159
  redesign. The read grant now has to be an **`AclFieldPermissions`** row instead.
159
160
 
160
- **Needed migration (proposed, on the TRUE-79533 branch):**
161
- `dbchanges2/Client/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql`, following the
162
- existing `Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql` precedent:
163
- - Resolve field ids via `Core.RecordFields` / `Core.Records` subselects (route `'entitlements'`,
164
- fields `'serviceAddressId'` **and** its sibling `'number'`) — never hardcode ids.
165
- - **Mirror the sibling `number` field's per-role grants**, but force **`isWritable = 0`**:
166
- `serviceAddressId` is written **server-side** by the guard's `postPost`, never by the API caller,
167
- so it must be read-only.
168
- - Guard each INSERT with `NOT EXISTS` (verified against the Client blank DDL:
169
- `AclFieldPermissions` is `UNIQUE (recordFieldId, roleId)`).
161
+ **The migration that ships (built + committed `b74f42a` on TRUE-79533):**
162
+ `dbchanges2/Client_Rate/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql`:
163
+ - Resolves field ids via cross-schema `Core.RecordFields` / `Core.Records` subselects (route
164
+ `'entitlements'`, field `'serviceAddressId'`) — never hardcodes ids.
165
+ - **Grants READ only (`isWritable = 0`)** — `serviceAddressId` is written **server-side** by
166
+ `_Model_Rate_Entitlement::postPost` via raw SQL, never by the API caller.
167
+ - **Mirrors the reader ROLE(s) of the sibling standard FK `saleItemId` — which is `role 1` in
168
+ `Client_Rate`** (the same role the earlier `c_serviceAddressId` custom grant used). Although
169
+ the cross-role probe showed roles 1/2/4 all lacked a grant, **role 1 is the only actual reader**
170
+ of this field (Rate's toga2-view service cards), so the grant is role-1 only.
171
+ - Guards each INSERT with `NOT EXISTS` — safe no-op until the field is registered
172
+ (`AclFieldPermissions` is `UNIQUE (recordFieldId, roleId)`).
173
+ - **Scoped to `Client_Rate`, not a `Client/` fan-out** — Rate is the only client whose UI reads
174
+ the field today. A `Client/` fan-out grant is the follow-up if another client's UI needs it.
170
175
 
171
176
  ## Beta data migration — old-column pins are orphaned by the redesign (beta only, NO prod impact)
172
177
 
@@ -289,6 +294,17 @@ live carrier waterfall is opt-in via `RUN_LIVE=1` (defaults off). Verified **18/
289
294
  the deleted `2026-07-07a` interceptor registration was redundant — and why keeping it would have
290
295
  been a latent hazard: a fresh environment running both would **double-register**, firing
291
296
  `prePost` twice. **Recommend adding a `NOT EXISTS` guard to the `2026-07-15` file.**
297
+ - **`AclFieldPermissions` has NO `isReadable` column — a ROW grants READ; `isWritable`
298
+ *additionally* grants WRITE** (reusable platform fact, verified against the api2 enforcement
299
+ code, not just schema). In `api2/Component/Api/V2/V2.php`, `getAclFieldPermissions()` (~line
300
+ 5731) sets the field key regardless of `isWritable`, and the read-authorization checks test key
301
+ **presence** (`isset`/`array_key_exists`). Consequences: to make a field API-**readable** you
302
+ insert an `AclFieldPermissions` row (`isWritable = 0` for read-only); to make it **writable** set
303
+ `isWritable = 1`. And promoting a custom (`c_`) field to a **standard** field means its read grant
304
+ must be **re-created** as a standard `AclFieldPermissions` row (keyed by the `Core.RecordFields`
305
+ id) — the old `AclCustomFieldPermissions` grant (keyed by `CustomRecordFields` id) does **not**
306
+ carry over. This is exactly why the `c_serviceAddressId` grant did not cover the standard field
307
+ and the launch-blocker migration above was needed.
292
308
  - **`Core.RecordFields` has no `foreignRecordId` column** — FK resolution for `serviceAddressId`
293
309
  is entirely **model-side** (the `_Model_Client_Entitlement` FK declaration). The RecordField row
294
310
  only carries type/identifier/childPolicy/precision, mirroring sibling FKs `saleItemId`/`vendorId`.
@@ -297,6 +313,17 @@ live carrier waterfall is opt-in via `RUN_LIVE=1` (defaults off). Verified **18/
297
313
 
298
314
  ## Change history
299
315
 
316
+ - 2026-07-23 — **READ-ACL grant BUILT — launch blocker resolved** (TRUE-79533, committed `b74f42a`).
317
+ Added `dbchanges2/Client_Rate/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql`: a
318
+ READ-only (`isWritable = 0`) `AclFieldPermissions` grant for the standard `serviceAddressId`,
319
+ resolving field ids cross-schema (route `entitlements`), `NOT EXISTS`-guarded. Final scope
320
+ corrected vs. the earlier proposal: **`Client_Rate` (not a `Client/` fan-out), role 1 only** —
321
+ mirroring the sibling standard FK `saleItemId`'s reader role (role 1 is the sole actual reader,
322
+ the toga2-view service cards). Recorded the reusable platform fact that **`AclFieldPermissions`
323
+ has no `isReadable` column — row presence grants READ, `isWritable` additionally grants WRITE**
324
+ (verified in `api2/Component/Api/V2/V2.php` `getAclFieldPermissions()` ~line 5731), which is why
325
+ a custom→standard promotion must re-create the read grant as an `AclFieldPermissions` row.
326
+ (mhammontree)
300
327
  - 2026-07-23 — **LAUNCH BLOCKER found: the standard `serviceAddressId` field has NO read ACL grant**
301
328
  (tcox). Probe-verified on beta: `GET /v2/entitlements?fields=serviceAddressId` → 403 `EZ-2` for
302
329
  roles 1/2/4 (`aclDatabase=CLIENT`). Standard fields are authorized via **`AclFieldPermissions`**
@@ -0,0 +1,69 @@
1
+ ---
2
+ type: session
3
+ slug: compass-retrofix2-sql-generator
4
+ title: Compass retroactive data-fix v2 — SQL generator rewrite
5
+ author: jcardinal
6
+ repos: [test, worker, library, _underscore]
7
+ framework: "2.0"
8
+ client: compass-usa
9
+ status: active
10
+ created: 2026-07-23
11
+ updated: 2026-07-23
12
+ ---
13
+
14
+ # Session: compass-retrofix2-sql-generator
15
+ **Date:** 2026-07-23
16
+ **Project/Repo:** test/@jeff/compass (script), operating on Client_Compass (2.0)
17
+ **Task:** Rewrite the Compass USA retroactive data-fix as a NEW standalone script that GENERATES SQL to a flat file (never writes the DB), fixing SO/PO item existence + the two item-link bridges bottom-up, then recursing all ItemFulfillments up to the Compass SO — replacing the old direct-write `retrofix.php`.
18
+
19
+ ---
20
+
21
+ ## What WORKED
22
+ - **New script built & lint-clean:** `D:\WWW\test\@jeff\compass\retrofix2.php` (`php -l` passes). Standalone CLI: `require_once('_.php'); App_Framework_Sandbox::initialize();`, read-only DB alias `db_prod2_compass`, writes SQL to `retrofix2.phase1.sql` / `retrofix2.phase2.sql`.
23
+ - **Two-pass PHASE model** (`const PHASE = 1|2`): Phase 1 generates item-link SQL → developer APPLIES it → re-run with PHASE=2 (now reads corrected bridges) → generates IF-recursion SQL. Chosen because Phase 2's up-mapping walks the item bridges Phase 1 fixes, and generate-only SQL can't see un-applied fixes.
24
+ - **Chain resolution** (`getChainForSo`): developer's LEFT-JOIN header query, deduped into distinct id sets per level (compassPoIds/odpSoIds/odpPoIds/agilantSoIds + odpPo→odpSo, compassPo→odpSo pairings). Validated against prod: a Compass SO can have MULTIPLE Compass POs; recent orders legitimately have partial chains.
25
+ - **Phase 1 L1 group logic** mirrors `library/app/api/toga2.php:778-897,1216-1352` in id-space: re-pulls Agilant SO from NetSuite (`App_NetSuite::getSalesOrder` by `c_netsuiteInternalSalesOrderId`), builds `memberItemId=>[groupItemId]` via `getItemDetails(...,'itemGroup',false)` on group-header lines (line has neither `rate` nor `amount`), matches Agilant SOI→ODP POI by (item OR group-item, quantity) non-consuming (1 POI → many SOIs). Memoizes getItemDetails per NS internal id.
26
+ - **Phase 2** ports `_underscore/Model/Client/ItemFulfillment.php::reconcileUpstreamLevel` as SQL: header walk, item walk grouped-by-upstream-SOI with bundle scaling `min(rawSum, rawSum*upQtyOrdered/downOrderedSum)`, broken-bridge guard, create/link upstream IF+IFI, propagate units + 3 tracking bridges (shared unitId/trackingNumberId), carries planned rows forward in-memory across the Agilant→ODP→Compass climb. New rows use generated uuid + `(SELECT id … WHERE uuid=…)` refs.
27
+ - **emit() guard** hard-blocks any write to `TrackingNumbers` or `Units` (only exact-table match; bridge tables like `ItemFulfillmentItemUnits_TrackingNumbers` correctly allowed).
28
+ - **Bug fixes this session (all lint-verified):**
29
+ - L4 false inserts — root cause: existing-links query used `WHERE b.purchaseOrderItemId IN (<Compass PO HEADER ids>)`; fixed to join `PurchaseOrderItems … purchaseOrderId IN (compassPoIds)`. Confirmed against SA133672 (SO 108392) which no longer emits its two spurious inserts.
30
+ - Phase 2 `ifIdExpr` "Undefined array key realId" — IFI handles use `ifiRealId`/`ifiUuid`, IF handles use `realId`/`uuid`; helper now accepts both.
31
+ - MySQL 1093 (self-referential UPDATE) — `ifIdExpr` now wraps uuid lookups in a derived table `(SELECT id FROM (SELECT id FROM t WHERE uuid=…) AS _rf2_<hash>)`; inline upstream-IF-link expr routed through it too.
32
+ - MySQL 1451 (FK on stale deletes) — deletes now strictly bottom-up: IFIU_TrackingNumbers → IFIU → ItemFulfillmentItems_TrackingNumbers → ItemFulfillmentItems (and unit-tracking → unit for leftover units).
33
+ - Order header now prints date: `# SA123456 (2026-06-15)` (worklist selects dateOrder) for resume tracking.
34
+
35
+ ## What did NOT work — DO NOT RETRY THESE
36
+ - **Relying on the plain `try/catch(Throwable)` around NetSuite calls to catch the SSL drop** ("SoapClient::__doRequest(): SSL: An existing connection was forcibly closed by the remote host"). The warning is intercepted by TOGA's GLOBAL `App_Error::handleError → handleException`, which (Sentry active) reports and can terminate the instance / not re-throw cleanly — so the retry loop never got a reliable shot. FIX APPLIED: `nsCall` now installs a LOCAL `set_error_handler` (throws ErrorException) around each attempt and `restore_error_handler()` after — so the warning is caught locally and retried. Do not go back to relying on the global handler.
37
+ - **Old `retrofix.php` itemId-only "purity" test** for item-group links — would delete legitimate group links (1 ODP PO item ↔ many Agilant SO items whose itemIds differ). Superseded by the NetSuite group-map approach. Do not reintroduce pure itemId matching at L1.
38
+
39
+ ## Not tried yet (candidates for next session)
40
+ - **End-to-end verification not yet done:** the idempotency re-run test (apply Phase 1 → re-run → expect empty) and the engine-parity check for a Flow-B order have NOT been run.
41
+ - **L4 Compass-SO↔Compass-PO bundle links** are intentionally NOT auto-created — bundle/config lines (`c_isConfiguration`/`parentSalesOrderItemId`/`bundleId`) are only logged as SKIP. Needs the bundle-split logic from `worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php` before enabling.
42
+ - Confirm the fresh NetSuite retry fix (local error handler) actually survives a real SSL drop in a full batch run.
43
+ - Phase 2 possible over-emission: already-linked IFIs may get redundant (harmless) upstream-link UPDATEs across multi-level climbs — validate on the parity test.
44
+ - IF `number` generation (`F#####` via MAX+1) collision risk vs concurrent prod IF creation — apply Phase 2 SQL promptly.
45
+
46
+ ## Current file state
47
+ | File | Status | Notes |
48
+ |------|--------|-------|
49
+ | D:\WWW\test\@jeff\compass\retrofix2.php | Created, lint-clean, working | Full 2-phase generator; all reported bugs fixed; NetSuite local-error-handler retry (5× backoff 3/6/9/12s) |
50
+ | D:\WWW\test\@jeff\compass\retrofix2.phase1.sql | Generated (test window) | Output artifact; L4 spurious-insert bug fixed since last gen — regenerate |
51
+ | D:\WWW\test\@jeff\compass\retrofix2.phase2.sql | Generated (test window) | Output artifact; 1093/1451 fixed since last gen — regenerate |
52
+ | C:\Users\JCardinal\.claude\plans\i-need-to-continue-declarative-kahan.md | Created | Approved implementation plan (context, phases, open items, verification) |
53
+ | D:\WWW\test\@jeff\compass\retrofix.php | Unchanged | Old direct-write version, kept for reference |
54
+
55
+ ## Decisions made
56
+ - **Generate SQL, never write the DB** (developer applies manually) — user requirement; SELECTs + NetSuite GETs only.
57
+ - **Two-pass PHASE constant** over single-pass in-memory bridge model — simpler/safer, matches rollout order (verify links first, then fulfillments). Rejected: single-pass modelling expected bridges in memory (too complex/error-prone to verify).
58
+ - **NetSuite group resolution via live `getItemDetails`** (per user direction) rather than any persisted TOGA bundle table — re-pulls each Agilant SO from NetSuite.
59
+ - **Flat SQL file, no transactions, `# SA###### (date)` comments** per user preference.
60
+ - **L4 conservative** (insert pure links, log suspected bundles, delete only exact dups) — avoids destroying legit bundle links pending the script-1 bundle logic.
61
+
62
+ ## Blockers
63
+ None blocking. Open dependency: Phase 2 requires the Phase 1 SQL to be APPLIED to the target DB first (two-pass), and full E2E verification is still pending.
64
+
65
+ ## Exact next step
66
+ > Regenerate Phase 1 over a small test window (set `START_DATE`/`END_DATE` to ~1 day, `PHASE=1`) in `D:\WWW\test\@jeff\compass\retrofix2.php`, run it (live NetSuite + prod-read), then run the idempotency check: apply `retrofix2.phase1.sql` to a LOCAL Client_Compass copy, re-run PHASE=1, and confirm the second output is empty. Then repeat for PHASE=2.
67
+
68
+ ---
69
+ _Saved by /session-save on 2026-07-23_
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.424",
3
+ "version": "1.0.426",
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",