toga-ai 1.0.616 → 1.0.618

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: Tools
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-04
10
- owners: ["jcardinal"]
9
+ updated: 2026-08-19
10
+ owners: ["jcardinal", "mhammontree"]
11
11
  files:
12
12
  - tools/mvc/errors/get.php
13
13
  - tools/mvc/errors/post.php
@@ -266,6 +266,14 @@ They were renamed from plural on 2026-08-01, after the pipeline was already in p
266
266
 
267
267
  ## Gotchas / known issues
268
268
 
269
+ - **⚠ Recipients attach to an `issueId`, so they cannot be configured in advance.** A new business
270
+ exception's recipients can only be added **after** the condition first fires and creates the
271
+ `Logs.Issue` row — declaring a stable `issueKey` in code does **not** let you pre-wire the
272
+ email list. Therefore the **first occurrence always routes to a developer ClickUp task**, and
273
+ somebody has to come back here and add recipients (which is also what flips `isManaged = 1`).
274
+ Live example: issue **208** `COMPASS_OFFICE_DEPOT_PO_IMPORT_REJECTED_OVER_QUANTITY` is still
275
+ ClickUp-routed because nobody did. See
276
+ [error reporting — Issue/Event capture](../../../../2.0/apps/_underscore/features/error-reporting-issue-event.md).
269
277
  - **⚠ OPEN BUG (found 2026-08-04, deliberately left unfixed pending the developer's go-ahead):
270
278
  `tools/mvc/errors/post.php` ~line 181, the merge-fingerprint action, will merge into the WRONG
271
279
  issue.** It takes a reference **or** a numeric id in **one** field and disambiguates with
@@ -312,6 +320,11 @@ They were renamed from plural on 2026-08-01, after the pipeline was already in p
312
320
 
313
321
  ## Change history
314
322
 
323
+ - 2026-08-19 — Documented (no code change) that **email recipients are keyed by `issueId`, so they
324
+ cannot be pre-configured** for a business exception that has never fired — the first occurrence
325
+ always routes to a developer ClickUp task, and adding a recipient here is what converts it to
326
+ business email (and sets `isManaged = 1`). Found while wiring a new Rate business exception.
327
+ (mhammontree)
315
328
  - 2026-08-05 — **Built:** the `/errors` console surfaces the new Issue-level RESOLVED lifecycle —
316
329
  an **Open/Resolved/All** status filter (default Open, allowlisted value), a **Resolved badge**
317
330
  (listing + detail header), a **`dtAutoResolved`** fact row, and the ClickUp-episodes column
@@ -20,7 +20,7 @@
20
20
  | [Re-pointing a DB alias mid-request (_Database::register park/restore)](features/database-alias-repointing.md) | `_Database` keys **all live per-database runtime state by the connection ALIAS** (`Client` / `_underscore::DB_CLIENT`, `ClientLogs`, `Archive`), **not** by the | _underscore/Database.php, _underscore/Query.php, api2/Component/Api/V2/V2.php, api2/Component/Api/CrossClient/CrossClient.php |
21
21
  | [2.0 Email Send Pipeline (queue + Send worker)](features/email-send-pipeline.md) | In 2.0, `_Email::send()` **does not transmit** — it queues the message. | _underscore/Email.php, _underscore/String.php, worker2/Worker/Infrastructure/Email/Send.php, _underscore/Model/Client/Logs/Email.php, _underscore/Model/Client/Logs/EmailAttachment.php |
22
22
  | [Client Email Template Sending](features/email-template-sending.md) | `_Model_Client_EmailTemplate` sends a stored, client-defined email template by UUID. | _underscore/Model/Client/EmailTemplate.php, _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php, _underscore/Email.php |
23
- | [Error Reporting — Issue/Event Capture, Fingerprinting & Aggregation](features/error-reporting-issue-event.md) | Platform-wide error reporting for TOGA 2.0, built on an **Issue / Event** aggregation model in the **shared Core Logs DB**. | _underscore/Error.php, _underscore/Database.php, _underscore/Exception/Business.php, api2/Controller/Index.php, worker2/Controller/Index.php, _underscore/Model/Core/Logs/Issue.php, _underscore/Model/Core/Logs/Event.php, _underscore/Model/Core/Logs/IssueFingerprint.php, _underscore/Model/Core/Logs/IssueClickupTask.php, _underscore/Model/Core/Logs/IssueEmailAddress.php, _underscore/Model/Core/Logs/IssueAreaOwner.php, dbchanges2/Logs/2026-07-30a - Error reporting Issues and Events.sql, dbchanges2/Logs/2026-08-01a - Rename tables to singular.sql, dbchanges2/Logs_Client/2026-08-01a - Drop Error table.sql, dbchanges2/Core/2026-07-30a - Error escalation cron job.sql |
23
+ | [Error Reporting — Issue/Event Capture, Fingerprinting & Aggregation](features/error-reporting-issue-event.md) | Platform-wide error reporting for TOGA 2.0, built on an **Issue / Event** aggregation model in the **shared Core Logs DB**. | _underscore/Error.php, _underscore/Database.php, _underscore/Exception/Business.php, api2/Controller/Index.php, worker2/Controller/Index.php, _underscore/Model/Core/Logs/Issue.php, _underscore/Model/Core/Logs/Event.php, _underscore/Model/Core/Logs/IssueFingerprint.php, _underscore/Model/Core/Logs/IssueClickupTask.php, _underscore/Model/Core/Logs/IssueEmailAddress.php, _underscore/Model/Core/Logs/IssueAreaOwner.php, worker2/Worker/Infrastructure/Errors.php, tools/mvc/errors/issue/get.php, tools/mvc/errors/post.php, dbchanges2/Logs/2026-07-30a - Error reporting Issues and Events.sql, dbchanges2/Logs/2026-08-01a - Rename tables to singular.sql, dbchanges2/Logs_Client/2026-08-01a - Drop Error table.sql, dbchanges2/Core/2026-07-30a - Error escalation cron job.sql |
24
24
  | [Record-Changed Event Publishing (_Event::publish to SQS)](features/event-publish-sqs.md) | `_Event::publish()` (in `_underscore/Event.php`) is the PHP side of the real-time event pipeline. | _underscore/Event.php |
25
25
  | [FIELD_STORAGE fields — per-row lazy hydration and the platform-wide missing-column 500](features/field-storage-row-hydration.md) | `FIELD_STORAGE` is the 2.0 field type for blob-backed columns (S3 or local folder). | _underscore/Model.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/Invoice.php, dbchanges2/Core/HISTORIC/2024/2024-11b - item-fulfillments.sql |
26
26
  | [Forecast.Sales NetSuite import engine (real-time webhook)](features/forecast-sale-import.md) | Real-time importer that takes a NetSuite **sale** record and writes its lines into `Forecast.Sales` (the Forecast2 revenue table). | worker2/Component/Forecast/SaleImport/SaleImport.php, worker2/Component/Forecast/Db/Db.php, _underscore/Component/Api/Netsuite/Netsuite.php, worker2/Worker/Netsuite/Invoice.php, worker2/Worker/Netsuite/CashSale.php, worker2/Worker/Netsuite/CreditMemo.php, worker2/Worker/Netsuite/CashRefund.php, worker2/Worker/Netsuite/JournalEntry.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Netsuite/SalesOrder.php, dbchanges2/Forecast/2026-06-26a - Add journalEntry to Sales transaction type enum.sql, test/@dave/test_invoice_lifecycle.php, test/@dave/test_je_lifecycle.php, test/@dave/test_creditmemo_lifecycle.php, test/@dave/test_cashsale_lifecycle.php, test/@dave/test_cashrefund_lifecycle.php, test/@dave/test_fetchrecord_routes.php, test/@dave/verify_je_classification.php, test/@dave/probe_je_accounts.php, test/@dave/probe_je_shape.php, test/@dave/fixer.php, test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/Junk Drawer/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-12
9
+ updated: 2026-08-19
10
10
  owners: ["dfranks", "jcardinal", "mhammontree", "ajean", "bala", "apeterson"]
11
11
  files:
12
12
  - _underscore/Error.php
@@ -20,6 +20,9 @@ files:
20
20
  - _underscore/Model/Core/Logs/IssueClickupTask.php
21
21
  - _underscore/Model/Core/Logs/IssueEmailAddress.php
22
22
  - _underscore/Model/Core/Logs/IssueAreaOwner.php
23
+ - worker2/Worker/Infrastructure/Errors.php
24
+ - tools/mvc/errors/issue/get.php
25
+ - tools/mvc/errors/post.php
23
26
  - dbchanges2/Logs/2026-07-30a - Error reporting Issues and Events.sql
24
27
  - dbchanges2/Logs/2026-08-01a - Rename tables to singular.sql
25
28
  - dbchanges2/Logs_Client/2026-08-01a - Drop Error table.sql
@@ -659,8 +662,52 @@ clientUserId). **Neither was built.** As built instead:
659
662
  clients) rather than client-user ids, so recipients who have **no login** can still be
660
663
  addressed.
661
664
 
665
+ ### ⚠ Recipients are keyed by `issueId`, NOT `issueKey` — so the FIRST occurrence always goes to a developer
666
+
667
+ **Corrects a natural (and wrong) assumption:** that declaring an `issueKey` in code lets you
668
+ pre-configure who gets emailed. It does not. Recipients attach to the **`Logs.Issue` row**, which
669
+ does not exist until the condition **first fires**.
670
+
671
+ - Path: **Tools → `/errors` → open the issue → "Email recipients" → "Add recipient"** (client,
672
+ email, TO/CC/BCC) — `tools/mvc/errors/issue/get.php` (~L959 "Email recipients"),
673
+ `tools/mvc/errors/post.php` (~L149).
674
+ - That write inserts `Logs.IssueEmailAddress` (`uuid`, `issueId`, `clientId`, `emailAddress`,
675
+ `toCcBcc`) and sets `Issue.isManaged = 1`. worker2 reads them via
676
+ `worker2/Worker/Infrastructure/Errors.php::loadRecipientsByIssueId()`.
677
+ - **Adding a recipient is what converts an issue from developer-ClickUp-task routing to business
678
+ email.** `clientId 0` = ALL CLIENTS (Rate is `Core.Clients` id 39).
679
+
680
+ **Consequence to plan around:** every brand-new business exception routes its **first** occurrence to
681
+ a **developer ClickUp task**; someone must then configure recipients. Production evidence — only two
682
+ keyed business issues exist: id **24** ref `1X` `COMPASS_SALES_ORDER_MITS_TRANSMIT_REJECTED`,
683
+ `isManaged = 1` (the working precedent), and id **208** ref `AG`
684
+ `COMPASS_OFFICE_DEPOT_PO_IMPORT_REJECTED_OVER_QUANTITY`, `isManaged = 0` — a live example of a
685
+ business issue still going to ClickUp because nobody ever configured recipients.
686
+
687
+ ### `captureException` without throwing is a supported pattern
688
+
689
+ `_Error::captureException(new _Exception_Business(...))` **records and notifies without throwing**,
690
+ which is the correct shape when one *leg* of a job is unresolved but the job itself is valid — see
691
+ `worker2/Controller/Index.php:286` and the worked case in
692
+ [Rate subscription cancellation](../../../clients/rate/features/subscription-cancellation.md)
693
+ (carrier cancel abandoned while the deactivation is legitimate). Wrap the reporting call in
694
+ `try/catch (Throwable)` so reporting can never fail the job, and `is_object()`-guard any property
695
+ read you feed it — `_Error` promotes a *"read property on int"* warning into a job-killing exception.
696
+
662
697
  ## Change history
663
698
 
699
+ - 2026-08-19 — Recorded that **email recipients are configured per `issueId`, not per `issueKey`**,
700
+ so they cannot be pre-configured before a condition first fires — the `Logs.Issue` row must exist
701
+ first, which means the **first occurrence of any new business exception always routes to a
702
+ developer ClickUp task**. Documented the Tools path (`/errors` → issue → "Email recipients" →
703
+ "Add recipient"), the `Logs.IssueEmailAddress` write + `Issue.isManaged = 1` side effect,
704
+ `loadRecipientsByIssueId()` on the worker2 side, `clientId 0` = all clients, and the production
705
+ census (only issue 24 `COMPASS_SALES_ORDER_MITS_TRANSMIT_REJECTED` is managed; issue 208
706
+ `COMPASS_OFFICE_DEPOT_PO_IMPORT_REJECTED_OVER_QUANTITY` still routes to ClickUp for want of
707
+ recipients). Also recorded **non-throwing
708
+ `_Error::captureException(new _Exception_Business(...))`** as a supported pattern for an
709
+ unresolved leg of an otherwise-valid job, with the Rate carrier-cancel worked case and the
710
+ `is_object()` guard. (mhammontree)
664
711
  - 2026-08-18 — Recorded the environment-provisioning gotcha: the `Logs` cluster tables
665
712
  (`Issue`/`Event`/`IssueFingerprint`/…) come from the `dbchanges2/Logs/` migrations, and an
666
713
  environment missing them fails inside `_Error::captureException()` (e.g. `"Table
@@ -27,8 +27,12 @@ selected expedited method auto-resets to **"Standard Ground"**. The behavior is
27
27
  driven** — a tenant opts in per shipping-method field via an `optionGates` entry; there is no
28
28
  `if (clientSlug === ...)` branching. Omitting `optionGates` means expedited is always shown.
29
29
 
30
- Applies today to all three tenants: **COMPASS**, **COMPASSCANADA** (English + French), and
31
- **QUAD**.
30
+ Applies today to: **COMPASS** (USER/MANAGER/SUPERUSER — **not ADMIN**, see below),
31
+ **COMPASSCANADA** (English + French), and **QUAD**. Because configs are **per role**
32
+ (`.../FIELDS/<CLIENT>/<LANG>/<ROLE>/CARTPAGE.ts`), opting a single role out of the gate is done
33
+ by dropping `optionGates: [EXPEDITED_SHIPPING_GATE]` from that one role's shipping-method field —
34
+ no shared-logic or client-branching change. **Compass USA ADMIN** now omits the gate, so admins
35
+ always see every method (Standard Ground + both expedited) in both regular and edit-order mode.
32
36
 
33
37
  ## Key files / entry points
34
38
  - `src/pages/Cart/helpers/shippingOptionGates.ts` (NEW) — pure helpers + types:
@@ -40,8 +44,10 @@ Applies today to all three tenants: **COMPASS**, **COMPASSCANADA** (English + Fr
40
44
  `EXPEDITED_SHIPPING_GATE` config constant: `optionNames` `["2nd Day EOB","Next Day Air"]`,
41
45
  `visibleWhen { item: "primaryItem", path: "item.itemCategory.name", operator: "equals",
42
46
  value: "COMPUTERS" }`.
43
- - 15 `CARTPAGE.ts` files (COMPASS x4 roles, COMPASSCANADA ENGLISH x4 + FRENCH x4, QUAD x3) —
44
- each shipping-method field opts in via `optionGates: [EXPEDITED_SHIPPING_GATE]`.
47
+ - `CARTPAGE.ts` files (COMPASS USER/MANAGER/SUPERUSER — **ADMIN opted out**, COMPASSCANADA
48
+ ENGLISH x4 + FRENCH x4, QUAD x3) — each shipping-method field opts in via
49
+ `optionGates: [EXPEDITED_SHIPPING_GATE]`. `COMPASS/ENGLISH/ADMIN/CARTPAGE.ts` intentionally
50
+ omits it (and its now-unused import).
45
51
  - `src/pages/Cart/view/cartForm/CartForm.tsx` — filters `shippingMethodOptions` through the
46
52
  gates (reactive on `cartData.cartBundles`) before the select renders; resolves the shipping
47
53
  field via `findShippingMethodField`; uses `SHIPPING_OPTION_LABEL_SEPARATOR` for the
@@ -103,6 +109,14 @@ behavior is expressed in config, not in code branches.
103
109
  on that doc's slice-2 work-list.
104
110
 
105
111
  ## Change history
112
+ - 2026-08-19 — **Compass USA ADMIN role opted out of the expedited gate.** Removed
113
+ `optionGates: [EXPEDITED_SHIPPING_GATE]` (and the now-unused import) from
114
+ `COMPASS/ENGLISH/ADMIN/CARTPAGE.ts`, so admins always see Standard Ground + Next Day Air +
115
+ 2nd Day EOB in both regular commerce mode **and** edit-order mode. Because configs are per
116
+ role, dropping the gate on the ADMIN config alone is the scoped customization — no
117
+ shared-logic or client-branching change. Side effect: in edit-order mode an existing order's
118
+ expedited selection is no longer hidden/reset to Standard Ground for admins.
119
+ USER/MANAGER/SUPERUSER still gate expedited behind a computer kit. (apeterson)
106
120
  - 2026-08-19 — No change to expedited visibility. Noted that the shared shipping-rule type
107
121
  gained an optional `requireNoOtherItemsInKit` flag (+ `bundleHasOtherItems()` helper) used by
108
122
  the separate Standard Ground cost waiver — see
@@ -51,9 +51,18 @@ four Compass USA role configs (USER/ADMIN/MANAGER/SUPERUSER).
51
51
  4. When the selected method qualifies, the effect in `CartPage` waives its `c_cost` to 0 before
52
52
  writing `salesOrder.shipping`.
53
53
 
54
- Resulting Compass USA behavior: bare computer kit (primary computer only) → Standard Ground
55
- free; computer kit **with** other items → charged $10; all non-computer kits → charged;
56
- expedited methods unchanged (still priced).
54
+ Resulting Compass USA behavior (full truth table, confirmed complete — no further code change):
55
+ - **Solo computer kit on Standard Ground** → **$0** (waived; the freight is covered by the
56
+ `LT-FREIGHT` fee). "Solo" is scoped to the **kit** (`bundleProgressContents` has no non-primary
57
+ items), not the whole order.
58
+ - **Computer kit WITH other items in the kit, or any non-computer kit** → charged Standard
59
+ Ground **$10** (not waived).
60
+ - **Expedited (Next Day Air / 2nd Day EOB) + computers** → charged the method's `c_cost`
61
+ (2nd Day **$50**, Next Day **$100**), **never waived**.
62
+
63
+ All dollar amounts live in **`ShippingMethods.c_cost` in the Compass USA client DB** (developer
64
+ confirmed correct), **not** in frontend code — the waiver logic only zeroes a `c_cost`, it never
65
+ sets a price.
57
66
 
58
67
  ## Gotchas
59
68
  - **The waiver only means what the flag says.** Before this fix the gate had no
@@ -68,6 +77,12 @@ expedited methods unchanged (still priced).
68
77
  a computer kit mis-categorized in the catalog will neither waive nor gate.
69
78
 
70
79
  ## Change history
80
+ - 2026-08-19 — Confirmed the Compass USA shipping-charge truth table is fully satisfied by the
81
+ existing logic (no code change): solo computer kit on Standard Ground → $0 (waived, covered by
82
+ `LT-FREIGHT`); kit with other items or any non-computer kit → $10; expedited + computers →
83
+ method `c_cost` (2nd Day $50 / Next Day $100), never waived. Documented that all dollar amounts
84
+ live in `ShippingMethods.c_cost` in the Compass USA client DB, not in frontend code, and that
85
+ "solo computer" is scoped to the kit not the order. (apeterson)
71
86
  - 2026-08-19 — Fixed an over-broad Standard Ground waiver for Compass USA computer-kit carts:
72
87
  added the optional `requireNoOtherItemsInKit` flag on `PrimaryItemShippingRule` and the
73
88
  `bundleHasOtherItems()` helper, and set the flag on
@@ -2,7 +2,7 @@
2
2
 
3
3
  | Doc | Framework | Summary | Files |
4
4
  |-----|-----------|---------|-------|
5
- | [Rate AIG Warranty Contract Creation (silent-failure interceptor)](features/aig-contract-creation.md) | 2.0 | When a Rate entitlement is created, `_Model_Rate_Entitlement::postPost` (`_underscore/Model/Rate/Entitlement.php`) creates an **AIG warranty contract** as a non | _underscore/Model/Rate/Entitlement.php, _underscore/ApiRequest.php, worker2/Worker/Monitors/RateEntitlement.php, worker2/Worker/Rate.php, api2/Component/Api/V2/V2.php, test/@Mark/Rate/verify_aig_contract_identifier_persistence.php, test/@Mark/Rate/audit_wh_missing_aig_contracts.php, test/@Mark/Rate/aig_contract_lookup.php |
5
+ | [Rate AIG Warranty Contract Creation (silent-failure interceptor)](features/aig-contract-creation.md) | 2.0 | When a Rate entitlement is created, `_Model_Rate_Entitlement::postPost` (`_underscore/Model/Rate/Entitlement.php`) creates an **AIG warranty contract** as a non | _underscore/Model/Rate/Entitlement.php, _underscore/ApiRequest.php, worker2/Worker/Monitors/RateEntitlement.php, worker2/Worker/Rate.php, api2/Component/Api/V2/V2.php, test/@Mark/Rate/verify_aig_contract_identifier_persistence.php, test/@Mark/Rate/audit_wh_missing_aig_contracts.php, test/@Mark/Rate/aig_contract_lookup.php, test/@Mark/Rate/cancel_aig_contract.php |
6
6
  | [Rate Monthly Reconciliation Report](features/monthly-reconciliation-report.md) | 1.0 | A monthly cron that emails an Excel reconciliation report covering all Rate subscription sales orders and their linked PayPal payments for the prior calendar mo | worker/crons/notifications/reports/rate/send_monthly_rate_purchases_report.php, worker/schedules/cron.worker.notification.json |
7
7
  | [Rate SalesOrder → NetSuite CashSale Export (postPost)](features/netsuite-cashsale-export.md) | 2.0 | Rate sells home-warranty / home-tech-support products. | _underscore/Model/Rate/SalesOrder.php, _underscore/Model/Rate/Item.php |
8
8
  | [Rate PayPal Subscription Purchase & Webhook Pipeline](features/paypal-subscription-purchase-webhook.md) | 2.0 | > # ⚠ STATUS (2026-08-10, TRUE-80575) — A FIXED (uncommitted), B DECIDED (not built) > > **A. | worker2/Worker/Rate.php, worker2/Config/production.ini, api2/Config/production.ini, _underscore/Component/Api/Paypal/Paypal.php, toga2-view/src/hooks/usePayPalSubscription.ts, toga2-view/src/services/paypalService.ts, toga2-view/src/pages/CheckOut/viewModel/useCheckoutPageViewModel.ts, toga2-view/src/pages/CheckOut/api/checkoutApi.ts, toga2-view/src/pages/Activation/view/Activation.tsx |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: rate
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-18
9
+ updated: 2026-08-19
10
10
  owners: [mhammontree, tcox]
11
11
  files:
12
12
  - _underscore/Model/Rate/Entitlement.php
@@ -17,6 +17,7 @@ files:
17
17
  - test/@Mark/Rate/verify_aig_contract_identifier_persistence.php
18
18
  - test/@Mark/Rate/audit_wh_missing_aig_contracts.php
19
19
  - test/@Mark/Rate/aig_contract_lookup.php
20
+ - test/@Mark/Rate/cancel_aig_contract.php
20
21
  related:
21
22
  - clients/rate/profile.md
22
23
  - ../../../2.0/apps/api2/features/api-payload-interceptors.md
@@ -147,6 +148,23 @@ it sent blanks and AIG rejected them. `ET100014` was never "unexplained" — it
147
148
  All three now have both an address and a `ContactPhoneNumbers` row, so a replay should be accepted
148
149
  — **but read the duplicate-contract rule below first.**
149
150
 
151
+ ## Data repair — `ET100013` cancelled at the carrier (2026-08-19, production)
152
+
153
+ Doug Winter — entitlement **39**, contact **226**, subscription **20** (`I-KE367533YH1L`):
154
+
155
+ 1. Backfilled `c_aigContractNumber = '1000045962407'` and `c_aigContractId = 45962407` (taken from
156
+ AIG's report; id derived by stripping `10000`). The developer ran the UPDATE directly — **no
157
+ `dbchanges2` migration retained**, matching the `ET100020` precedent.
158
+ 2. Cancelled at the **live** carrier — HTTP **200** `{"errors":null,"successful":true}`.
159
+
160
+ The subscription was already `isActive 0`, `dateEnd = dateCancelled = 2026-08-18`, with
161
+ `dtRequestToCancel` **NULL** — i.e. cancelled by the **webhook handler**, not the portal `cancelApi`.
162
+
163
+ **STILL OPEN — `ET100014` (Kimberly Stearns) and `ET100015` (Daniel Moran)** are the same shape: WH,
164
+ **both** identifiers NULL, previously 400'd by depth starvation. They need their **contract numbers
165
+ from AIG's report** before they can be cancelled. Note this doc's replay guidance targets
166
+ `POST /contract` (which *creates* coverage) — **these two need `/contract/cancel` instead.**
167
+
150
168
  ## ⚠ A NULL identifier does NOT mean the contract is missing (pre-TRUE-81049 rows)
151
169
 
152
170
  **The most important operational rule in this doc.** On any entitlement created **before**
@@ -157,12 +175,64 @@ the contract, our columns were NULL.
157
175
  **Replaying such a row creates a DUPLICATE contract at the carrier.** Before any replay or repair:
158
176
 
159
177
  1. Query `Logs_Rate.Api` for a **2xx `POST .../contract`** for that customer.
160
- 2. **Match on the customer EMAIL, not `contractNumber`** — the `contractNumber` we sent was a
161
- throwaway uuid that matches nothing on our side.
178
+ 2. **Match on the customer EMAIL, not `contractNumber`** — the `contractNumber` we sent was
179
+ *usually* a throwaway uuid that matches nothing on our side (but not always — see
180
+ "AIG holds contracts our API never created" below).
162
181
  3. If a success exists → **backfill** the identifiers from AIG's logged response. Never replay.
163
182
  4. If the log window contains **zero** rows at all → the answer is **INCONCLUSIVE**, not "never
164
183
  attempted."
165
184
 
185
+ ## ⚠⚠ `POST /contract/cancel` REQUIRES `contractNumber` — the shipped cancel had NEVER worked
186
+
187
+ **Verified against the LIVE carrier on 2026-08-19** (`aigContractId 45962407`):
188
+
189
+ | Payload | AIG response |
190
+ |---|---|
191
+ | `{aigContractId, cancelRequestDate}` — **what we shipped** | HTTP **400** `{"errors":[{"errorCode":"76","errorDescription":"Required fields not provided"}],"successful":false}` |
192
+ | `{aigContractId, cancelRequestDate, contractNumber}` | HTTP **200** `{"errors":null,"successful":true}` |
193
+
194
+ `worker2/Worker/Rate.php::cancelAigContract()` (~L566) sent only the first shape, so **every AIG
195
+ cancellation we have ever attempted failed** — and because that method `catch`es, `error_log()`s and
196
+ returns `false`, it failed **silently**. This doc previously recorded only that `/contract/cancel`
197
+ exists; it did not record its required fields. That gap is the bug.
198
+
199
+ **This is independent of the NULL-identifier problem.** Even a row with a populated
200
+ `c_aigContractId` would have 400'd. Fixing the identifiers alone would not have cancelled anything.
201
+ Working payload is `{aigContractId, cancelRequestDate, contractNumber}` — send the stored
202
+ `c_aigContractNumber`.
203
+
204
+ ## `c_aigContractId` is derivable from `c_aigContractNumber` — strip the leading `10000`
205
+
206
+ Verified across **all 7** `Client_Rate` entitlements that hold both values:
207
+
208
+ `1000045859462`→`45859462` (ET100011) · `1000045886119`→`45886119` (ET100012) ·
209
+ `1000046067341`→`46067341` (ET100016) · `1000046232878`→`46232878` (ET100017) ·
210
+ `1000046411721`→`46411721` (ET100018) · `1000046482036`→`46482036` (ET100019) ·
211
+ `1000046483092`→`46483092` (ET100020)
212
+
213
+ AIG ids also run **sequentially by purchase date**, which independently corroborates a derived
214
+ value. **Why it matters:** AIG exposes no lookup endpoint, so when all you have is a contract number
215
+ off AIG's report, you can still compute the `aigContractId` the cancel call needs.
216
+
217
+ ## ⚠ AIG holds contracts our API never created — a NULL identifier does not prove *absence* either
218
+
219
+ Doug Winter `ET100013` (entitlement 39) is the proof, and it **corrects** this doc's earlier claim
220
+ that the `contractNumber` AIG holds is always a throwaway uuid matching nothing on our side:
221
+
222
+ - Our **only** outbound `/contract` call for him was `Logs_Rate.Api` id `4180950`, 2026-04-01,
223
+ HTTP **400** (depth starvation — `contractNumber ""`, `saleItemId ""`, blank customer
224
+ id/name/email; AIG answered *"SaleItemId field is required / ContractNumber field is required"*).
225
+ - **Zero** outbound calls on 2026-05-01 — so the recurring PayPal charge does **not** re-trigger
226
+ contract creation. Only *entitlement creation* does.
227
+ - **Yet AIG's own report showed an ACTIVE contract**, CCN
228
+ `f8685561-2177-6e62-16d0-4d70865db51b` — **our real stored entitlement uuid** — under AIG CN
229
+ `1000045962407`.
230
+
231
+ **Practical rule: our logs cannot prove a customer is uncovered. AIG's report is the authority.**
232
+ A NULL identifier plus no logged success means *inconclusive*, in **both** directions — the earlier
233
+ rule covered "coverage may exist despite NULL columns"; this adds "coverage may exist even when our
234
+ only logged attempt failed."
235
+
166
236
  ## AIG exposes NO contract lookup endpoint (verified 2026-08-18)
167
237
 
168
238
  Only three AIG routes exist for us: `POST /authentication/login`, `POST /contract`, and
@@ -197,6 +267,22 @@ and every other `/authentication/login` row. Readable by anyone with production
197
267
  > The credential **value** is deliberately not recorded anywhere in this knowledge base — only
198
268
  > where the exposure lives.
199
269
 
270
+ **Third instance of the same exposure class (found 2026-08-19):** an uncaught DB connect failure
271
+ prints the production DB **username and password** in the stack trace — observed live this session
272
+ with `ENVIRONMENT=production`.
273
+
274
+ The mechanism is **not** positional arguments: `_Database_Driver_Mysql::connect()`
275
+ (`_underscore/Database/Driver/Mysql.php:10`) already calls `mysqli_connect()` with **named**
276
+ arguments. PHP renders the arguments of **every** stack frame, and PHP 8.2+ marks
277
+ `mysqli_connect`'s password with `#[\SensitiveParameter]` — so *that* frame safely shows
278
+ `Object(SensitiveParameterValue)`. **Our wrapper's own `string $password` parameter carries no such
279
+ attribute, so the wrapper's frame prints it in plaintext.** There are **zero** uses of
280
+ `#[\SensitiveParameter]` anywhere in the non-vendor codebase, so this applies to every credential
281
+ our own code accepts as a parameter.
282
+
283
+ Not Rate-specific; noted here only because it was hit while working this flow. (The other two: this
284
+ AIG auth body, and the plaintext live PayPal secret in `api2/Config/production.ini`.)
285
+
200
286
  ## Diagnostic & repair tooling (`test/@Mark/Rate/`)
201
287
 
202
288
  Reusable team diagnostics, not throwaways. The CLI scripts bootstrap `worker2`/`_underscore`.
@@ -219,6 +305,25 @@ Reusable team diagnostics, not throwaways. The CLI scripts bootstrap `worker2`/`
219
305
  *If AIG ever provides a lookup endpoint, promote this into the `tools` repo behind the Active
220
306
  Directory login (TRUE-78064 SSO pattern) so support can self-serve.*
221
307
 
308
+ - **`cancel_aig_contract.php`** — the reusable AIG cancel/repair one-off (TRUE-81049, 2026-08-19).
309
+ **Dry-run by default**; `APPLY=1` to act; `CONFIRM_PRODUCTION_AIG=1` required for the live
310
+ carrier; `NO_DB=1` for the prod-cluster-unreachable case (skips the DB read/backfill and prints
311
+ AIG's response as the only record); `CANCEL_EXTRA` takes JSON merged into the payload for field
312
+ iteration; `FORCE=1` overrides the refusal to cancel a subscription that is not already cancelled
313
+ on our side. It **backfills the identifiers BEFORE calling the carrier**, because the carrier call
314
+ is irreversible and the write is not. Prints AIG's verbatim response body.
315
+
316
+ **⚠ Test-harness gotcha — a worker2-bootstrapped local script CANNOT reach `Client_<Tenant>` in
317
+ production.** `worker2/Config/production.ini` has a **single** `[database] hostname` pointing at the
318
+ **Core** cluster, and in production `Core` / `Client_*` / `Logs*` are **separate clusters** — so
319
+ `_Database::register()` for `Client_Rate` fails with *"Unknown database"*. Locally everything sits
320
+ behind one endpoint, so this **only bites against production**;
321
+ `audit_wh_missing_aig_contracts.php` has the same limitation and was only ever run against
322
+ sandbox-dev. That is what `NO_DB=1` above exists for. **Corollary:** setting
323
+ `_ApiRequest::isLoggingEnabled = true` while `DB_CLIENT_LOGS` is unregistered makes the log write
324
+ throw *after* the HTTP call, and `_underscore`'s error handler then **exits** — silently killing a
325
+ script between two API calls with no output. **Logging must follow database availability.**
326
+
222
327
  **Test-harness gotcha (bit us live):** in a verify script, **reads go to the READ host and cannot
223
328
  see the WRITE host's uncommitted rows** — a successful write reads back as a silent no-op and a
224
329
  passing fix looks broken. Call `_Database::transactionCommit()` before **every** read-back, and
@@ -264,9 +369,11 @@ creation vs. cancellation. This is an accepted, documented limitation — not an
264
369
  **outbound response** (`api2/Component/Api/V2/V2.php:5934` passes `$outData`), which the engine
265
370
  returns and never saves. This is why the AIG identifiers silently never persisted. See the
266
371
  [API payload interceptors](../../../2.0/apps/api2/features/api-payload-interceptors.md) doc.
267
- - **`c_aigContractId` is what CANCELS the carrier contract** — `worker2` `_Worker_Rate:521` uses
268
- it. A NULL means the AIG contract **can never be cancelled**: the customer stops paying us and
269
- coverage stays active at AIG. That is the real cost of the persistence bug.
372
+ - **Cancelling the carrier contract needs BOTH `c_aigContractId` AND `c_aigContractNumber`** —
373
+ `worker2` `_Worker_Rate:521` gates on the id, but `/contract/cancel` rejects a payload without the
374
+ number (see the required-fields section above). A NULL means the AIG contract **can never be
375
+ cancelled**: the customer stops paying us and coverage stays active at AIG. That is the real cost
376
+ of the persistence bug.
270
377
  - **Ordering: TRUE-81049 must land before/with TRUE-80575.** TRUE-80575's worker2 handler
271
378
  re-reads the entitlement and alerts when `c_aigContractId` is empty — until identifiers
272
379
  persist, that check false-alarms on **every** purchase. TRUE-80575 (1 `_underscore` commit + 3
@@ -295,6 +402,31 @@ creation vs. cancellation. This is an accepted, documented limitation — not an
295
402
 
296
403
  ## Change history
297
404
 
405
+ - 2026-08-19 — TRUE-81049 (fix parked in that already-scheduled deployment; no new ticket):
406
+ **`POST /contract/cancel` requires `contractNumber`, so the shipped AIG cancel had NEVER
407
+ worked.** Verified against the live carrier on `aigContractId 45962407` — the shipped
408
+ `{aigContractId, cancelRequestDate}` returns HTTP 400 errorCode 76 *"Required fields not
409
+ provided"*; adding `contractNumber` returns HTTP 200 `successful: true`. Silent because
410
+ `cancelAigContract()` catches/logs/returns false; **independent of the NULL-identifier bug** (a
411
+ populated id would still have 400'd). Fixed in `worker2/Worker/Rate.php` (~L566), commit
412
+ `a399145` on branch TRUE-81049 — **committed, not pushed.** Also recorded: `c_aigContractId` is
413
+ **derivable** from `c_aigContractNumber` by stripping the leading `10000` (verified on all 7 rows
414
+ holding both; AIG ids are sequential by purchase date), which is the workaround for the missing
415
+ lookup endpoint; **AIG holds contracts our API never created** — `ET100013`'s only `/contract`
416
+ call was a 400 and the recurring PayPal charge does *not* retry creation, yet AIG's report shows
417
+ an ACTIVE contract carrying **our real entitlement uuid** as its CCN, correcting the
418
+ "throwaway uuid matches nothing" claim and establishing that **AIG's report, not our logs, is the
419
+ authority** on coverage; the **`ET100013` production repair** (backfill + live cancel, direct
420
+ UPDATE, no migration kept) with `ET100014`/`ET100015` **still open** pending their contract
421
+ numbers from AIG; the new `test/@Mark/Rate/cancel_aig_contract.php` repair tool; the
422
+ **prod-cluster test-harness limit** (worker2's single `production.ini` hostname cannot reach
423
+ `Client_Rate`; and enabling `_ApiRequest` logging without `DB_CLIENT_LOGS` kills the script
424
+ silently between API calls); and a **third instance of the credential-exposure class** —
425
+ an uncaught DB connect failure prints user+password in the stack trace, because our
426
+ `_Database_Driver_Mysql::connect()` wrapper's `string $password` parameter lacks
427
+ `#[\SensitiveParameter]` (PHP 8.2+ protects `mysqli_connect`'s own frame but not ours; zero uses
428
+ of the attribute exist in the non-vendor codebase). Location only, never the value.
429
+ (mhammontree)
298
430
  - 2026-08-18 — TRUE-81049: **corrected two claims in this doc that were wrong.** (1) The success
299
431
  path did **not** persist the generated uuid or the AIG identifiers — `postPost` receives the
300
432
  **outbound response** (`V2.php:5934` → `$outData`), so `$payload->c_aigContract*` and the
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: rate
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-06
9
+ updated: 2026-08-19
10
10
  owners: [mhammontree, tcox]
11
11
  files:
12
12
  - _underscore/Model/Rate/Subscription.php
@@ -39,6 +39,7 @@ related:
39
39
  - ../../../2.0/apps/_underscore/features/config-group-access.md
40
40
  - ../../../2.0/apps/api2/features/environment-variable-drives-underscore-branch.md
41
41
  - ../../../2.0/standards/backend-testing.md
42
+ - ../../../2.0/apps/_underscore/features/error-reporting-issue-event.md
42
43
  ---
43
44
 
44
45
  ## Summary
@@ -220,6 +221,46 @@ RecordFields id literals were verified correct per environment.
220
221
  read the cancellation columns.** This is why mirroring `dateEnd`'s grants was the right call rather
221
222
  than inventing a worker-specific role.
222
223
 
224
+ ## The carrier leg — a missing AIG identifier is now REPORTED, not silently skipped (2026-08-19)
225
+
226
+ `handleSubscriptionDeactivation` (`worker2/Worker/Rate.php` ~L519) guarded the AIG cancel with
227
+ `if ($entitlement && !empty($entitlement->c_aigContractId))` — and had **no `else`**. So a missing
228
+ identifier abandoned the **carrier leg with no record anywhere**: the customer stopped paying while
229
+ coverage stayed **active at AIG**. This defect was found by a **human noticing**, not by any system.
230
+
231
+ Now the else branch calls `reportAigContractNotCancellable()`, which captures
232
+ `_Exception_Business(issueKey: 'RATE_AIG_CONTRACT_CANCEL_MISSING_IDENTIFIER', URGENCY_HIGH)` naming
233
+ **which column** is missing.
234
+
235
+ **The pattern worth copying (general to 2.0):**
236
+ `_Error::captureException(new _Exception_Business(...))` **records the issue and notifies WITHOUT
237
+ throwing.** That is the right tool when *part* of a job is unresolved but the job itself is valid —
238
+ throwing here would abort a subscription deactivation that is entirely correct; only the carrier leg
239
+ is open. Precedent for the non-throwing call: `worker2/Controller/Index.php:286`. The whole
240
+ reporting call sits inside `try/catch (Throwable)` so **a reporting path can never fail the job**.
241
+
242
+ Splitting the failure modes also **sharpened the thrown exception**: a blank identifier no longer
243
+ reaches the `throw`, so a thrown exception now means exactly one thing — **the carrier rejected a
244
+ well-formed request.** Previously a blank `contractNumber` threw a *technical* exception and aborted
245
+ a valid deactivation.
246
+
247
+ ### ⚠ The report MUST be gated on Whole Home Warranty, or it fires HIGH on every routine cancel
248
+
249
+ Rate's data model makes most cancellation events legitimately carrier-free:
250
+
251
+ - `Items.id 3` = **"Whole Home Warranty - Monthly"** — the **only** AIG-backed product.
252
+ - `Items.id 1` and `2` are **tech-support** products with **no carrier contract by design**.
253
+ - TRUE-80282's portal self-service cancellation **refuses warranties**, so **tech** cancellations
254
+ are the **dominant** `BILLING.SUBSCRIPTION.CANCELLED` case.
255
+
256
+ Ungated, the missing-identifier report would raise a HIGH-urgency **business** alert on every
257
+ routine tech cancellation. `isWholeHomeWarrantyEntitlement()` therefore uses the **same authority as
258
+ the purchase guard and the DB dedup** — sale-item **title keyword `warranty`** OR **`saleItemId 3`**
259
+ — and checks **both** the nested `saleItem` object **and** the flat `saleItemId` FK, so a **shallow
260
+ payload cannot disable the gate**. It is `is_object()`-guarded because `_Error` promotes a
261
+ *"read property on int"* warning into an **exception that would kill the job**. Verified
262
+ `ET100013`/`14`/`15` all carry `saleItemId 3` / title "Whole Home Warranty - Monthly".
263
+
223
264
  ## ⚠ SECURITY — pre-existing unscoped ACL grant undermines this feature's core guarantee
224
265
 
225
266
  **Needs its own ticket. Found by reading the permission tables only — NOT exploited, NOT tested.**
@@ -327,6 +368,23 @@ feature rather than failing the page.
327
368
 
328
369
  ## Change history
329
370
 
371
+ - 2026-08-19 — TRUE-81049 (fix parked in that scheduled deployment; commit `a399145`, branch
372
+ TRUE-81049 in worker2 — **committed, not pushed**): closed the **silent skip of the AIG carrier
373
+ leg** in `handleSubscriptionDeactivation`. The old `if (… !empty($entitlement->c_aigContractId))`
374
+ had no `else`, so a missing identifier left coverage **active at the carrier** with no record
375
+ anywhere — found by a human, not a system. New `reportAigContractNotCancellable()` captures a
376
+ **non-throwing** `_Exception_Business('RATE_AIG_CONTRACT_CANCEL_MISSING_IDENTIFIER',
377
+ URGENCY_HIGH)` naming the missing column, inside `try/catch (Throwable)` so reporting can never
378
+ fail the job; precedent `worker2/Controller/Index.php:286`. Splitting the failure modes means the
379
+ **thrown** exception now means exactly one thing (carrier rejected a well-formed request) instead
380
+ of also firing on a blank identifier and aborting a valid deactivation. Gated by new
381
+ `isWholeHomeWarrantyEntitlement()` on the purchase guard's authority (title keyword `warranty` OR
382
+ `saleItemId 3`, checked on **both** the nested object and the flat FK, `is_object()`-guarded)
383
+ because `Items.id 1`/`2` are tech products with no carrier contract and the portal refuses
384
+ warranties — so tech is the dominant cancellation case and an ungated report would alert HIGH on
385
+ every routine cancel. See also the AIG doc: `/contract/cancel` also **requires
386
+ `contractNumber`**, so the carrier cancel had never succeeded regardless of the identifiers.
387
+ (mhammontree)
330
388
  - 2026-08-06 — TRUE-80282 **testing & deploy**: **verified end-to-end on beta** (PayPal sandbox) —
331
389
  `POST /v2/subscriptions/cancel` returned 200 with `dateCancel: 2026-08-27`, a real sandbox
332
390
  agreement was cancelled, and `Subscriptions` id 84 confirmed `isActive = 1`, `dateEnd` untouched,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.616",
3
+ "version": "1.0.618",
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",