toga-ai 1.0.489 → 1.0.491

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: Tools
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-30
9
+ updated: 2026-08-01
10
10
  owners: ["jcardinal"]
11
11
  files:
12
12
  - tools/mvc/errors/get.php
@@ -32,10 +32,10 @@ happening and were deliberately never ticketed. Nobody had that view before.
32
32
  ## How it works
33
33
 
34
34
  - **Curate** subject, description, `minimumUrgency`, assignee, and mute window.
35
- - **Manage per-client email recipients** (`IssueEmailAddresses`). `clientId = 0` means **all
35
+ - **Manage per-client email recipients** (`IssueEmailAddress`). `clientId = 0` means **all
36
36
  clients** and requires an explicit confirmation flag; the list view renders it as
37
37
  **"ALL CLIENTS"**.
38
- - **Merge fingerprints** — repoint an `IssueFingerprints` row at an existing Issue. Without this,
38
+ - **Merge fingerprints** — repoint an `IssueFingerprint` row at an existing Issue. Without this,
39
39
  the fingerprint/Issue split is inert: a refactor that shifts line numbers produces a new Issue
40
40
  and the curated one is orphaned.
41
41
  - **Any save sets `isManaged`**, which does double duty: it protects the curated text from being
@@ -59,8 +59,21 @@ from `Core.Databases` id **11** (`worker2` `CORE_LOGS_DATABASE_ID`); `Logs` is o
59
59
  `_underscore` *register alias*. Read the real name from `Core.Databases` before configuring a new
60
60
  environment, or the console silently points at a database that may not exist.
61
61
 
62
+ ## Table names are SINGULAR
63
+
64
+ The Core Logs tables this console queries are `Issue`, `Event`, `IssueFingerprint`,
65
+ `IssueClickupTask`, `IssueEmailAddress`, `IssueAreaOwner` — **singular**, unlike every other
66
+ table this app touches. That is the deliberate `Logs`-cluster convention (see the
67
+ [2.0 error-reporting doc](../../../2.0/apps/_underscore/features/error-reporting-issue-event.md)).
68
+ They were renamed from plural on 2026-08-01, after the pipeline was already in production.
69
+
62
70
  ## Gotchas / known issues
63
71
 
72
+ - **A 2.0 table rename breaks this page silently until it is run.** This console has **no model
73
+ layer** — `get.php` and `post.php` interpolate table names into hand-written `App_Database`
74
+ SQL (40+ literal occurrences between them). Nothing here fails at deploy time; it fails when
75
+ a curator opens the page. Any rename in the shared Core Logs DB must be applied to both files
76
+ in the same release as the migration.
64
77
  - **This is a 1.0 app reading a 2.0 shared database.** Access is plain `App_Database` via the
65
78
  `db_toga2logs` alias, not the `_Model` layer — so none of the 2.0 model protections apply.
66
79
  Escape every interpolated value and allowlist every identifier (see the 1.0 back-end standard).
@@ -72,6 +85,11 @@ environment, or the console silently points at a database that may not exist.
72
85
 
73
86
  ## Change history
74
87
 
88
+ - 2026-08-01 — Updated every hardcoded table name in `tools/mvc/errors/get.php` (25+ refs) and
89
+ `post.php` (15+ refs) for the Core Logs singular rename (`Issues`→`Issue`, `Events`→`Event`,
90
+ `IssueFingerprints`→`IssueFingerprint`, `IssueClickupTasks`→`IssueClickupTask`,
91
+ `IssueEmailAddresses`→`IssueEmailAddress`, `IssueAreaOwners`→`IssueAreaOwner`). Recorded that
92
+ this 1.0 console has no model layer to absorb a 2.0 shared-schema rename. (jcardinal)
75
93
  - 2026-07-30 — Built as part of TRUE-78188: new `/errors` triage/curation console over the
76
94
  shared Core Logs DB via a new `db_toga2logs` alias; curation sets `isManaged` (protects text
77
95
  and exempts from GC); fingerprint merging; per-client recipient management with an explicit
@@ -5,7 +5,8 @@
5
5
  | [Proposed — git-sourced base+overlay JSON authoring for the Surface layer](architecture/surface-authoring-proposal.md) | A **proposal / handoff recommendation** (not implemented) that the Surface layer's *authoring* model move off hand-authored SQL against the `SurfaceOverrides` E | _underscore/Model/Core/Surface.php, _underscore/Model/Client/SurfaceOverride.php |
6
6
  | [_underscore Framework Architecture](architecture.md) | `_underscore` is the shared PHP backend framework for **all 2.0 applications**. | _underscore/_underscore.php, _underscore/Loader.php, _underscore/Framework.php, _underscore/Model.php, _underscore/Database.php, _underscore/Query.php, _underscore/Route.php, _underscore/Component.php |
7
7
  | [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, _underscore/Model/Core/Page.php, _underscore/Model/Client/TrackingNumber.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql |
8
- | [Address Validation (carrier waterfall + validateAddress scripted endpoint)](features/address-validation.md) | `_Model_Client_Address::validateAddress` verifies a US address against a **carrier waterfall (USPS → FedEx → UPS)** and returns a single canonical, carrier-norm | _underscore/Model/Client/Address.php |
8
+ | [Address Uniqueness Normalization (unit identifier + 5-digit ZIP comparison)](features/address-uniqueness-normalization.md) | When a business rule says *"only one X per physical address"*, comparing address rows field-for-field does **not** work: the same dwelling is spelled many diffe | _underscore/Model/Rate/Entitlement.php |
9
+ | [Address Validation (carrier waterfall + validateAddress scripted endpoint)](features/address-validation.md) | `_Model_Client_Address::validateAddress` verifies a US address against a **carrier waterfall (USPS → FedEx → UPS)** and returns a single canonical, carrier-norm | _underscore/Model/Client/Address.php, _underscore/Component/Library/Carriers/Usps/Usps.php |
9
10
  | [_ApiRequest — JSON encode/decode & api-logging behavior](features/apirequest-json-content-type.md) | `_ApiRequest` is the 2.0 outbound HTTP client. | _underscore/ApiRequest.php |
10
11
  | [Assortment Name Translation (AssortmentTranslations sidecar)](features/assortment-name-translation.md) | Serves Assortment (product-grouping) **names** in multiple languages by adding a per-language **sidecar** table `AssortmentTranslations`, reusing the platform's | _underscore/Model/Client/AssortmentTranslation.php, dbchanges2/Client/2026-06-26a - AssortmentTranslations.sql, dbchanges2/Core/2026-06-26a - AssortmentTranslationsRecord.sql, dbchanges2/Client/2026-06-26b - AssortmentTranslationsAcl.sql |
11
12
  | [Asynchronous Query Execution (writes-only, via Worker)](features/async-query-execution.md) | `_Query` can run a **write** query asynchronously so a long/slow write does not hold a request-scoped DB connection open long enough to hit **"MySQL server has | _underscore/Query.php, worker2/Worker/Infrastructure/Database.php, worker2/Worker/Team/Transcripts.php |
@@ -15,7 +16,7 @@
15
16
  | [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 |
16
17
  | [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, worker2/Worker/Infrastructure/Email/Send.php |
17
18
  | [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 |
18
- | [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/Exception/Business.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/Core/2026-07-30a - Error escalation cron job.sql |
19
+ | [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/Exception/Business.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 |
19
20
  | [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 |
20
21
  | [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 |
21
22
  | [isFulfillable Propagation Up the SO↔PO Chain](features/fulfillable-item-propagation.md) | `Items.isFulfillable` is a boolean that gates whether a storefront line's **Qty Fulfilled** cell is actionable. | _underscore/Model/Client/Item.php, _underscore/Model/Compass/Item.php, dbchanges2/Core/2026-07-17 - Items isFulfillable RecordField.sql, dbchanges2/Core/2026-07-17 - RegisterItemIsFulfillableInterceptors.sql, dbchanges2/Client/2026-07-17 - ItemsisFulfillable.sql |
@@ -31,5 +32,6 @@
31
32
  | [Table-View Hyperlink Columns (meta → ACL → computed URL → render)](features/tableview-hyperlink-columns.md) | Any 2.0 table-view column can render its value as a clickable link instead of plain text. | _underscore/Model/Client/TableView.php, _underscore/Model/Client/TrackingNumber.php, api2/Component/Api/V2/V2.php, toga2-supply/src/api/toga.ts, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/formatTableData.tsx, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/convertData.tsx, toga2-supply/src/components/ui/Tables/hooks/useDataTableState.tsx, dbchanges2/Client/2026-07-20 - TrackingNumberHyperlinkAndFieldPermission.sql |
32
33
  | [Tracking-Number Bridge Migration (ASN / Item Fulfillment / Item Receipt)](features/tracking-number-bridges.md) | Shipment tracking numbers used to live as **scalar FK columns** (`trackingNumberId`, `returnTrackingNumberId`) directly on the lowest-level "unit"/"item" tables | api2/Component/Api/V2/V2.php, _underscore/Model/Client/AdvanceShippingNoticeItemUnit.php, _underscore/Model/Client/AdvanceShippingNoticeItemUnits/TrackingNumber.php, _underscore/Model/Client/ItemFulfillmentItemUnits/TrackingNumber.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Prudential/AdvanceShippingNotice.php, _underscore/Model/Compass/AdvanceShippingNotice.php, _underscore/Trait/Netsuite/ItemFulfillment.php, api2/Component/Api/Cxml/Cxml.php, dbchanges2/Client/2026-06-10 - TrackingNumberBridges.sql, dbchanges2/Core/2026-06-10 - TrackingNumberBridges.sql, dbchanges2/Client_Prudential/2026-06-15 - ItemFulfillmentTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Quad/2026-06-18a - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Nychh/2026-06-19b - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Growrk/2026-07-13a - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Nychh/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql, dbchanges2/Client_Quad/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql |
33
34
  | [Units for Items for Purchase Orders — Data Structure](features/units-for-items-for-purchase-orders.md) | Describes how unit (serialized inventory) data is linked to sales-order and purchase-order line items behind the `units-for-items-for-purchase-orders` TableView | |
35
+ | [USPS DPV Deliverability Verdict (is this address actually insurable/shippable?)](features/usps-dpv-deliverability.md) | **USPS returning HTTP 200 with a populated address is NOT evidence that the address is deliverable.** The authoritative signal is USPS's **DPV (Delivery Point V | _underscore/Component/Library/Carriers/Usps/Usps.php, _underscore/Model/Client/Address.php, _underscore/Model/Rate/Entitlement.php |
34
36
  | [Refreshing a Local Dev Database from Beta (dev-sandbox)](workflows/local-db-refresh-from-beta.md) | How to reset a local 2.0 dev database from the **beta / dev-sandbox** environment: dump each schema (`Core`, `Client_<Id>`, `Logs_<Id>`, …) from the beta host, | api2/Config/, _underscore/Loader.php, _underscore/Model/Client/BundleTranslation.php, api2/Component/Api/V2/V2.php, toga25-supply/sync_compasscanada_schema.sql |
35
37
  | [Running a 2.0 App Locally (browser, end-to-end via api2)](workflows/running-a-2.0-app-locally.md) | The full dependency chain required to run a 2.0 client app **through the browser**, end-to-end, against a **local `api2`** (e.g. | _underscore/Environment.php, _underscore/Config.php, _underscore/Database.php, _underscore/Route.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php, api2/index.php, api2/.htaccess |
@@ -0,0 +1,96 @@
1
+ ---
2
+ title: "Address Uniqueness Normalization (unit identifier + 5-digit ZIP comparison)"
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-31
10
+ owners: [mhammontree]
11
+ files:
12
+ - _underscore/Model/Rate/Entitlement.php
13
+ related:
14
+ - ./address-validation.md
15
+ - ./usps-dpv-deliverability.md
16
+ - ../../../clients/rate/features/whole-home-warranty-purchase-guard.md
17
+ ---
18
+
19
+ ## Summary
20
+
21
+ When a business rule says *"only one X per physical address"*, comparing address rows
22
+ field-for-field does **not** work: the same dwelling is spelled many different ways
23
+ (`Suite 500` / `STE 500` / `Ste. 500` / `#500`), and carrier validation returns a **ZIP+4**
24
+ while every legacy row holds 5 digits. This doc records the two normalization primitives
25
+ built for the Rate Whole Home Warranty per-address guard, both of which are **reusable for any
26
+ "one per address" rule** on the platform.
27
+
28
+ Both live as private helpers on `_Model_Rate_Entitlement` today (first use); promote them to a
29
+ shared helper when a second consumer appears.
30
+
31
+ ## `normalizeUnitIdentifier()` — compare the UNIT, discard the designator
32
+
33
+ Line 2 is compared by **unit identifier only**. Everything that is not `A–Z0–9` is stripped,
34
+ the result upper-cased, and any leading designator word removed:
35
+
36
+ | Input `line2` | Normalized unit |
37
+ |---|---|
38
+ | `Suite 500`, `STE 500`, `Ste. 500`, `#500`, `Apt 500`, `No. 500`, `500` | `500` |
39
+ | `4B`, `12A`, `PH2`, `101-A` | `4B`, `12A`, `PH2`, `101A` |
40
+ | `""`, `NULL`, invisible-only (see below) | *(no unit)* |
41
+
42
+ **Why discard the designator instead of maintaining a synonym list.** Within a single
43
+ `line1 + city + ZIP5`, the designator carries **no distinguishing information** — there is no
44
+ real building with both a "Suite 500" and an "Apt 500". Discarding it removes any synonym
45
+ table to maintain and errs toward **MATCHING**, which is the correct bias for a *uniqueness
46
+ guard* (a false match blocks a purchase and a human resolves it; a false non-match silently
47
+ creates the duplicate the rule exists to prevent).
48
+
49
+ **An EMPTY unit means "the whole building"** and therefore conflicts with **ANY** unit at the
50
+ same street address, **in both directions**. So a building-wide warranty and a per-unit
51
+ warranty can never coexist.
52
+
53
+ **`trim()` alone is insufficient.** The `[^A-Z0-9]` strip is what collapses the invisible
54
+ characters that survive `trim()` — NBSP `U+00A0`, ZWSP `U+200B`, ZWJ `U+200D`, BOM `U+FEFF`,
55
+ ideographic space `U+3000`. An invisible-only `line2` normalizes to *no unit*, identical to
56
+ `NULL`, which is the intent.
57
+
58
+ ## `comparableZip()` — compare the first 5 digits only
59
+
60
+ USPS returns **ZIP+4** (verified live: `60503-7832`) while every pre-existing `Addresses` row
61
+ holds 5 digits. An exact ZIP comparison therefore missed **every legacy row**. Uniqueness
62
+ compares `substr(digits, 0, 5)`.
63
+
64
+ ## Where the comparison happens: SQL narrows, PHP decides
65
+
66
+ The dedup query narrows candidates on `line1 + city + 5-digit ZIP + state`, and the **unit
67
+ comparison runs in PHP** over the returned candidates.
68
+
69
+ **Why not do it in SQL.** Real normalization cannot be expressed in SQL without a nested
70
+ `REPLACE()` chain, which is both unreadable and **unindexable** (no index can serve it, so it
71
+ degrades to a full scan). Narrow in SQL on indexable columns, then compare in PHP.
72
+
73
+ The candidate fetch is capped at **`DEDUP_CANDIDATE_LIMIT = 500`** with an `error_log` canary
74
+ if the cap is ever hit — a single street address with 500+ address rows means something else
75
+ is wrong.
76
+
77
+ ## Gotchas / known issues
78
+
79
+ - **Never judge address duplication from a query that omits `line2`.** During this session
80
+ three beta entitlements looked like duplicates on a `line1 + zip` projection and were in
81
+ fact **distinct units** (`STE 1900` / `# 4` / `STE 500`). Always select `line2` before
82
+ concluding two addresses are the same — and certainly before deleting anything.
83
+ - **Bias is deliberately toward matching.** If a legitimate distinct unit is ever blocked, fix
84
+ it by making the *guard's* message actionable, not by adding designator sensitivity back.
85
+ - **Promote before the second copy.** These helpers are Rate-private only because Rate is the
86
+ first consumer. Copy-pasting them into a second client model is how normalization rules
87
+ drift apart.
88
+
89
+ ## Change history
90
+ - 2026-07-31 — Built `normalizeUnitIdentifier()` (designator-discarding unit comparison,
91
+ invisible-character collapse, empty-unit-conflicts-with-everything semantics) and
92
+ `comparableZip()` (5-digit comparison, because USPS returns ZIP+4 and legacy rows hold 5)
93
+ for the Rate WH per-address guard (TRUE-80487). Recorded the SQL-narrows/PHP-decides split
94
+ (real normalization is unindexable in SQL), `DEDUP_CANDIDATE_LIMIT = 500` + canary, and the
95
+ "never judge duplication without `line2`" lesson from three beta rows misread as duplicates.
96
+ (mhammontree)
@@ -6,13 +6,16 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-07
9
+ updated: 2026-07-31
10
10
  owners: [mhammontree]
11
11
  files:
12
12
  - _underscore/Model/Client/Address.php
13
+ - _underscore/Component/Library/Carriers/Usps/Usps.php
13
14
  related:
14
15
  - ../../api2/features/scripted-api-post-body-args.md
15
16
  - ../../api2/architecture.md
17
+ - ./usps-dpv-deliverability.md
18
+ - ./address-uniqueness-normalization.md
16
19
  - ../../../clients/rate/features/whole-home-warranty-purchase-guard.md
17
20
  ---
18
21
 
@@ -34,6 +37,16 @@ Whole Home Warranty purchase guard).
34
37
  `zip4`/`zip5`.
35
38
  - **On failure** (all carriers fail) returns the **last (UPS) response** with
36
39
  `success:false` (and an `error`).
40
+ - **Since 2026-07-31** the USPS leg also returns `DPVConfirmation`, `vacant`, `business` and
41
+ `corrections[]`, and `_Model_Client_Address` exposes `DPV_*` constants +
42
+ `withValidationDefaults()` — see
43
+ [USPS DPV deliverability](./usps-dpv-deliverability.md).
44
+
45
+ > **`success:true` does NOT mean the address is deliverable.** USPS returns HTTP 200 with a
46
+ > populated address even when the secondary unit is **missing or invented**. Any caller whose
47
+ > business rule depends on the address being real (insurance, warranty, shipment to a unit)
48
+ > must read the **DPV verdict**, not just `success`. See
49
+ > [USPS DPV deliverability](./usps-dpv-deliverability.md).
37
50
 
38
51
  ## The scripted endpoint is registered in the DB, not in source (parity gap)
39
52
 
@@ -68,8 +81,19 @@ in `dbchanges2` source** (closing the parity gap noted below) rather than only i
68
81
  distinct from the older global `validateAddress` route's `data.addresses.validateAddress`).
69
82
 
70
83
  The PHP method (`_Model_Client_Address::validateAddress`) already existed and was unchanged — it was
71
- simply **unrouted in source** until this migration set exposed it. New scripted routes should follow
72
- this pattern (route + `AclRecordScripts` both in `dbchanges2`).
84
+ simply **unrouted in source** until this migration set exposed it.
85
+
86
+ > **⚠ CORRECTION (2026-07-31): that grant migration never took effect.**
87
+ > `Client/2026-07-16c - AddressValidateScriptAcl.sql` **silently no-ops** — Core script **28**
88
+ > (`addresses/validate`) has **ZERO** `AclRecordScripts` rows in `Client_Rate`. The file resolves
89
+ > the Core script id with a cross-database `Core.*` read from a fan-out `Client/` folder, which
90
+ > cannot work: in production `Core` is a **separate cluster**, and on a developer machine the
91
+ > literal schema `Core` is the **1.0** database (the 2.0 core is `Core_2`) which has no
92
+ > `RecordScripts` table at all. Either way the `SET @var` stays NULL and the `NOT EXISTS`-guarded
93
+ > INSERT inserts nothing. **Do not copy this file as the pattern.** For a *client-specific*
94
+ > scripted API, register it as a **client-side `CustomRecordScripts`** instead — registration and
95
+ > grant then live in one database (see
96
+ > [Record Scripts → client-side CustomRecordScripts](../../api2/features/record-scripts.md)).
73
97
 
74
98
  ## Client variations
75
99
 
@@ -83,8 +107,37 @@ per the standard client carrier config.
83
107
  migrations into a fresh DB. Register scripted endpoints in `dbchanges2` going forward.
84
108
  - **Waterfall requires agreement on success.** The success shape is only returned when the
85
109
  carriers agree; a `success:false` result carries the last (UPS) response, not a merged one.
110
+ - **Scripted-API parity gaps (audited 2026-07-31).** Source and live DB disagree in both
111
+ directions — verify against the live `Core.RecordScripts` rows, never source alone:
112
+ - **`addresses/validateAddress` (id 17), the endpoint `toga2-view` actually calls, exists ONLY
113
+ as a live DB row** — there is **no `dbchanges2` migration for it**. A freshly provisioned
114
+ environment would **404** on the endpoint the frontend depends on.
115
+ - **`tracking-numbers/reprint` is MISSING ENTIRELY from production `Core.RecordScripts`** — so
116
+ `Core/2026-07-01a - TrackingNumberReprintRecordScript.sql` never took effect in production.
117
+ - **`addresses/validate` (id 28) has zero `AclRecordScripts` grants in `Client_Rate`** (see the
118
+ correction above).
119
+ - **`Core.RecordScripts` ids are NOT stable across environments** — max id is 27 in production,
120
+ 28 local, 28 client-sandbox, 30 dev-sandbox, and the *same* script has different ids per
121
+ environment. Never hardcode a `RecordScripts` id, and never assume "the next available id"
122
+ means the same thing anywhere else. (`Core.Records` ids, by contrast, **are** seeded
123
+ identically everywhere — Addresses = 13.)
124
+ - **USPS OAuth credentials are committed in plaintext** in
125
+ `_underscore/Component/Library/Carriers/Usps/Usps.php` (constants near the top of the file);
126
+ they belong in the `_Config` INI. Rotate and relocate — see the
127
+ [DPV doc](./usps-dpv-deliverability.md). *(Location only; no value recorded.)*
86
128
 
87
129
  ## Change history
130
+ - 2026-07-31 — TRUE-80487: recorded that **`success:true` does not mean deliverable** (USPS
131
+ returns 200 for a missing/invented unit) and split the DPV verdict out into
132
+ [usps-dpv-deliverability.md](./usps-dpv-deliverability.md); the USPS leg now also returns
133
+ `DPVConfirmation`/`vacant`/`business`/`corrections`. **Corrected the 2026-07-17 entry below:**
134
+ `Client/2026-07-16c - AddressValidateScriptAcl.sql` **silently no-ops** (script 28 has zero
135
+ `AclRecordScripts` rows in `Client_Rate`) because it reads `Core.*` from a fan-out `Client/`
136
+ folder — unreachable in prod (separate cluster) *and* locally (`Core` is the **1.0** DB; the 2.0
137
+ core is `Core_2`). It is **not** the pattern to copy. Added the audited scripted-API parity gaps
138
+ (script 17 exists only in the live DB with no migration; `tracking-numbers/reprint` absent from
139
+ production) and the `Core.RecordScripts` id-drift fact. Also recorded the committed plaintext
140
+ USPS credential location. (mhammontree)
88
141
 
89
142
  - 2026-07-17 — TRUE-79191: added a **`dbchanges2`-registered** `GET /addresses/validate` route to the
90
143
  same `validateAddress` method (Core `RecordScripts` route `validate` + Client `AclRecordScripts`
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-30
9
+ updated: 2026-08-01
10
10
  owners: ["dfranks", "jcardinal"]
11
11
  files:
12
12
  - _underscore/Error.php
@@ -18,6 +18,8 @@ files:
18
18
  - _underscore/Model/Core/Logs/IssueEmailAddress.php
19
19
  - _underscore/Model/Core/Logs/IssueAreaOwner.php
20
20
  - dbchanges2/Logs/2026-07-30a - Error reporting Issues and Events.sql
21
+ - dbchanges2/Logs/2026-08-01a - Rename tables to singular.sql
22
+ - dbchanges2/Logs_Client/2026-08-01a - Drop Error table.sql
21
23
  - dbchanges2/Core/2026-07-30a - Error escalation cron job.sql
22
24
  related:
23
25
  - ../architecture.md
@@ -45,9 +47,23 @@ re-implementing persistence.
45
47
 
46
48
  ## Data model (shared Core Logs DB)
47
49
 
48
- Six tables, created by `dbchanges2/Logs/2026-07-30a - Error reporting Issues and Events.sql`:
49
- `Issues`, `IssueFingerprints`, `Events`, `IssueEmailAddresses`, `IssueClickupTasks`,
50
- `IssueAreaOwners`.
50
+ Six tables, created by `dbchanges2/Logs/2026-07-30a - Error reporting Issues and Events.sql` and
51
+ renamed to their final **singular** names by
52
+ `dbchanges2/Logs/2026-08-01a - Rename tables to singular.sql`:
53
+ `Issue`, `IssueFingerprint`, `Event`, `IssueEmailAddress`, `IssueClickupTask`, `IssueAreaOwner`.
54
+
55
+ > **⚠ The `Logs` cluster is the one place that uses SINGULAR table names.** Everywhere else on
56
+ > the platform tables are plural (`Orders`, `Customers`) — see the 2.0 back-end standard. In
57
+ > `Logs` the tables are named for the *thing* (`Issue`, `Event`), matching how they are referred
58
+ > to verbally ("the Issue table", "API Logs", not "APIs Logs"). **When you add a new table to the
59
+ > `Logs` cluster, name it singular.** Do not "fix" these to plural, and do not carry the singular
60
+ > form outside `Logs`.
61
+
62
+ Renamed 2026-08-01 (`Issues`→`Issue`, `Events`→`Event`, `IssueFingerprints`→`IssueFingerprint`,
63
+ `IssueClickupTasks`→`IssueClickupTask`, `IssueEmailAddresses`→`IssueEmailAddress`,
64
+ `IssueAreaOwners`→`IssueAreaOwner`). The migration renames the tables *and* their foreign keys
65
+ and indexes; the `const TABLE` on every `_Model_Core_Logs_*` model was updated to match. Older
66
+ notes below and in related docs may still quote the plural names — the shape is unchanged.
51
67
 
52
68
  **An Issue is never client-scoped.** An Issue is identified by *code*, and code is global — the
53
69
  same bug hitting six clients is **one** Issue. Per-client scoping lives on the occurrence
@@ -183,6 +199,18 @@ Sentry-removal ticket cannot complete until the 1.0 port does.
183
199
  `varchar(255)` (`errorMessage`, `subject`) **throws** on `->save()`. Inside the handler's
184
200
  `catch(Throwable)` the throw is swallowed and the row is silently dropped — precisely when a
185
201
  long message matters most. Truncate to column width before `save()`.
202
+ - **`const TABLE` must track a table rename, and nothing warns you.** A `_Model` subclass whose
203
+ `const TABLE` points at a renamed table fails only at query time. When a `Logs` table is
204
+ renamed, the matching `_underscore/Model/Core/Logs/*.php` `const TABLE` must change in the
205
+ **same** deploy as the migration.
206
+ - **1.0 apps break on a 2.0 table rename.** Tools' `/errors` console reads the shared Core Logs
207
+ DB with hand-written `App_Database` SQL, so it has no model layer to absorb a rename — every
208
+ literal table name in `tools/mvc/errors/get.php` and `post.php` had to be edited by hand.
209
+ Grep the 1.0 side before renaming anything in a **shared** 2.0 database.
210
+ - **The per-client `Logs_Client.Error` table is dead.** Superseded by the Issue/Event pipeline;
211
+ the code that wrote it is commented out in `_Error.php`, the `Model/Core/Logs/Error.php` and
212
+ `Model/Client/Logs/Error.php` models were deleted, and the table is dropped by
213
+ `dbchanges2/Logs_Client/2026-08-01a - Drop Error table.sql`. Do not reintroduce it.
186
214
  - **Shared-schema migration collision.** Because these tables live in the *shared* Core Logs
187
215
  DB, only **one** migration may `CREATE` them; later migrations only `ALTER`.
188
216
  - **Pre-existing committed secrets in this area (not fixed — out of scope, report only).**
@@ -239,6 +267,16 @@ clientUserId). **Neither was built.** As built instead:
239
267
 
240
268
  ## Change history
241
269
 
270
+ - 2026-08-01 — **Decided: `Logs` cluster tables use SINGULAR names.** Renamed all six
271
+ post-deployment (`Issues`→`Issue`, `Events`→`Event`, `IssueFingerprints`→`IssueFingerprint`,
272
+ `IssueClickupTasks`→`IssueClickupTask`, `IssueEmailAddresses`→`IssueEmailAddress`,
273
+ `IssueAreaOwners`→`IssueAreaOwner`) via
274
+ `dbchanges2/Logs/2026-08-01a - Rename tables to singular.sql` (tables + FKs + indexes), and
275
+ updated `const TABLE` on every `_Model_Core_Logs_*` model. This is a naming-convention
276
+ alignment, not a behavior change: the `Logs` cluster is named for the thing ("API Logs", not
277
+ "APIs Logs") while the rest of the platform stays plural. Also deleted the obsolete
278
+ `Model/Core/Logs/Error.php` and `Model/Client/Logs/Error.php` and dropped the per-client
279
+ `Logs_Client.Error` table. (jcardinal)
242
280
  - 2026-07-30 — Reworked TRUE-78188: six-table schema in the shared Core Logs DB (Issue is
243
281
  global by code; client scope lives on Events/IssueEmailAddresses); `_Error::captureException()`
244
282
  as the single capture entry point; trace-frame fingerprinting with the message excluded
@@ -0,0 +1,103 @@
1
+ ---
2
+ title: "USPS DPV Deliverability Verdict (is this address actually insurable/shippable?)"
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-31
10
+ owners: [mhammontree]
11
+ files:
12
+ - _underscore/Component/Library/Carriers/Usps/Usps.php
13
+ - _underscore/Model/Client/Address.php
14
+ - _underscore/Model/Rate/Entitlement.php
15
+ related:
16
+ - ./address-validation.md
17
+ - ./address-uniqueness-normalization.md
18
+ - ../../../clients/rate/features/whole-home-warranty-purchase-guard.md
19
+ ---
20
+
21
+ ## Summary
22
+
23
+ **USPS returning HTTP 200 with a populated address is NOT evidence that the address is
24
+ deliverable.** The authoritative signal is USPS's **DPV (Delivery Point Validation)**
25
+ confirmation code, which the carrier waterfall previously **discarded**. This doc records what
26
+ DPV means, the live-verified verdicts, and the platform mapping now applied.
27
+
28
+ USPS will happily echo back an **invented** secondary unit and still return 200 — so adopting
29
+ the "recommended" address alone does **not** fix a bad unit. **The DPV field must be read.**
30
+
31
+ ## The verdicts (VERIFIED against live USPS responses 2026-07-31)
32
+
33
+ | Address probed | `DPVConfirmation` | Meaning |
34
+ |---|---|---|
35
+ | `1904 IONE LN, AURORA IL 60503` (no unit) | **`Y`** | deliverable exactly as given |
36
+ | `1904 IONE LN` + `UNIT 200` (invented; single-family) | **`S`** | secondary present but **not confirmed** |
37
+ | `2511 VERSAILLES AVE, NAPERVILLE IL 60540` (no unit) | **`D`** | secondary **MISSING** — building is subdivided |
38
+ | `2511 VERSAILLES AVE` + `# 1` (invalid) | **`S`** | secondary not confirmed |
39
+
40
+ The `D` case additionally returns `corrections[].code` **`32`** — *"Default address: … more
41
+ information is needed (such as an apartment, suite, or box number)."*
42
+
43
+ **Platform mapping:**
44
+
45
+ | DPV | Action |
46
+ |---|---|
47
+ | `Y` | proceed |
48
+ | `D` | reject — **unit required** |
49
+ | `S` | reject — **unit not verified** |
50
+ | `N` | reject |
51
+ | `''` / absent | **unknown → PROCEED** (see decision below) |
52
+
53
+ ## DECIDED: unknown DPV proceeds (fails OPEN, deliberately)
54
+
55
+ FedEx and UPS — the waterfall's fallbacks — **do not report DPV at all**. If a fallback carrier
56
+ answered, the DPV verdict is unknown. That case **proceeds**: failing closed would block
57
+ **every** purchase during a USPS outage, and for the uniqueness use case the
58
+ [dedup guard](./address-uniqueness-normalization.md) still prevents duplicates independently.
59
+ This is a conscious availability-over-strictness trade; do not "harden" it without a
60
+ business-owner decision.
61
+
62
+ ## What was captured
63
+
64
+ - **`Usps.php`** now captures `DPVConfirmation`, `vacant`, `business`, and `corrections[]` off
65
+ the USPS response. `vacant` and `business` are **free** in `additionalInfo` and are now
66
+ carried through as potential underwriting inputs (no rule consumes them yet).
67
+ - **`_Model_Client_Address`** gained `DPV_*` constants plus **`withValidationDefaults()`**,
68
+ which is **purely additive**: it is implemented as `$response + [defaults]`, so the **left
69
+ operand wins** and a real carrier verdict can never be clobbered by a default.
70
+
71
+ ## Carriers cannot tell you DWELLING TYPE
72
+
73
+ A recurring ask ("is this an apartment building?") is **not answerable from carrier data**:
74
+
75
+ - UPS `AddressClassification` and FedEx classification only distinguish **Residential vs
76
+ Commercial** — an apartment building is *Residential*.
77
+ - The only actionable signal available is USPS DPV **"secondary unit required"** (`D`).
78
+ - **True dwelling type requires property data** — an assessor/parcel feed or a
79
+ Melissa/Smarty-class vendor. That is a **procurement decision, not a code one.**
80
+
81
+ ## Gotchas / known issues
82
+
83
+ - **HTTP 200 ≠ deliverable.** This is the whole point of the doc. Success + a populated
84
+ address is the *normal* response for a missing or invented unit.
85
+ - **USPS does not strip an invented unit.** It echoes `UNIT 200` back verbatim. Adopting the
86
+ recommended address does not sanitize it.
87
+ - **Plaintext USPS OAuth credentials are committed in source** — the client id and secret are
88
+ hardcoded as constants near the top of
89
+ `_underscore/Component/Library/Carriers/Usps/Usps.php`. They belong in the `_Config` INI
90
+ alongside the DB credentials. **Treat them as compromised: rotate and move to SSM / EB
91
+ environment properties.** Pre-existing, not introduced by this work. *(Location +
92
+ remediation only — no value is recorded anywhere.)*
93
+
94
+ ## Change history
95
+ - 2026-07-31 — Discovered that the carrier waterfall was **discarding** USPS DPV, and built
96
+ capture + enforcement (TRUE-80487): `Usps.php` now returns `DPVConfirmation`/`vacant`/
97
+ `business`/`corrections`, `_Model_Client_Address` gained `DPV_*` constants and the additive
98
+ `withValidationDefaults()`, and Rate's WH guard enforces `Y` proceed / `D` unit-required /
99
+ `S` unit-unverified / `N` reject / unknown proceed. Verdicts verified against four live USPS
100
+ probes; recorded that USPS echoes an invented unit back with a 200, that unknown DPV fails
101
+ **open** by decision, and that carriers cannot supply dwelling type (only Residential vs
102
+ Commercial) so an apartment-vs-house rule needs property data. Also recorded the committed
103
+ plaintext USPS OAuth credential location for rotation. (mhammontree)
@@ -20,5 +20,5 @@
20
20
  | [V2 API error/message codes (EV/EZ troubleshooting map)](features/v2-api-error-codes.md) | The V2 JSON engine (`Component/Api/V2/V2.php`) returns short **message codes** in the response `error` field, grouped by family: `EN-*` authentication, `EZ-*` a | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, api2/Component/Api/V2/Response/Oauth/Oauth.php, api2/Controller/Index.php, toga2-supply/src/globalTypes.ts, toga2-supply/src/pages/Orders/api/OrdersApi.ts, _underscore/Model/Client/TrackingNumber.php |
21
21
  | [V2 REST query contract (params, where grammar, encoding, ACL behavior)](features/v2-rest-query-contract.md) | What an **HTTP client** has to get right to query the Toga v2 REST API: which query params are recognized, the exact `where` grammar, how the query string is (n | api2/Component/Api/V2/V2.php |
22
22
  | [V2 reverse hasMany collections must be named in the fetch fields whitelist](features/v2-reverse-hasmany-fields-whitelist.md) | In the V2 JSON engine, a **reverse hasMany** relationship — the collection of child records that foreign-key back to a parent (e.g. | api2/Component/Api/V2/V2.php |
23
- | [AWS CodePipeline Deployment via CodeConnections (GitHub → Elastic Beanstalk)](workflows/codepipeline-codeconnections-deploy.md) | 2.0 apps (`api2`, `_underscore`) are deployed through **AWS CodePipeline**. | api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, api2/ebs/register_instance_to_shared_application_load_balancer.php |
23
+ | [AWS CodePipeline Deployment via CodeConnections (GitHub → Elastic Beanstalk)](workflows/codepipeline-codeconnections-deploy.md) | 2.0 apps (`api2`, `_underscore`) are deployed through **AWS CodePipeline**. | api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, api2/ebs/register_instance_to_shared_application_load_balancer.php, api2/.platform/hooks/prebuild/git.sh, api2/.ebextensions/git.php, api2/.ebextensions/git.sandbox-dev.json |
24
24
  | [New Environment Configuration & Provisioning (api2)](workflows/environment-configuration-and-provisioning.md) | What it takes for a 2.0 API environment (e.g. | api2/Config/<environment>.ini, api2/Controller/Index.php, dbchanges2/Core/2026-06-16a - DatabaseHosts for new QA QC stage demo environments.sql, dbchanges2/Logs/, _underscore/Route.php |
@@ -136,6 +136,56 @@ metadata model.
136
136
  endpoint goes in `Client_True`. The generic **`Client/`** folder runs against **every**
137
137
  tenant DB, so putting an ACL grant there hands the endpoint to all clients.
138
138
 
139
+ > **⚠ The "resolve the Core id by route from the client file" convention above is NOT runnable
140
+ > for the `AclRecordScripts` grant.** `AclRecordScripts.recordScriptId` links to
141
+ > `Core.RecordScripts` by **NUMERIC id** (there is no uuid reference column), so a `Client_*`
142
+ > migration must read `Core` to resolve it — which is impossible in production (separate
143
+ > clusters) **and equally broken in local dev**: locally the 2.0 core schema is **`Core_2`**, and
144
+ > the schema literally named `Core` is the **1.0** database, which has no `RecordScripts` and no
145
+ > `RecordFields`. So a literal `Core.<Table>` reference resolves to the wrong (or missing) schema
146
+ > in *both* environments, and a `NOT EXISTS`-guarded INSERT inserts nothing without erroring.
147
+ > **A clean local run is therefore never proof that such a migration worked.** This is not
148
+ > theoretical: `Client/2026-07-16c - AddressValidateScriptAcl.sql` **silently no-ops**, leaving
149
+ > Core script 28 with zero grants in `Client_Rate`. For a **client-specific** script, use the CustomRecordScripts pattern below.
150
+
151
+ ## ✅ REUSABLE PATTERN — register a CLIENT-SPECIFIC scripted API as a `CustomRecordScript`
152
+
153
+ **When a scripted API is declared on a client model (`_Model_<Slug>_X`), register it in the
154
+ CLIENT database as a `CustomRecordScript` — NOT in `Core.RecordScripts`.** First applied in
155
+ `dbchanges2/Client_Rate/2026-07-31a - WarrantyAvailabilityCustomRecordScript.sql`
156
+ (`_Model_Rate_Entitlement::warrantyAvailability`).
157
+
158
+ **Why (three independent reasons):**
159
+
160
+ 1. **One database, so the grant is resolvable.** `CustomRecordScripts` **and**
161
+ `AclCustomRecordScripts` are **both** `DB_CLIENT`
162
+ (`_Model_Client_CustomRecordScript` / `_Model_Client_AclCustomRecordScript`). Registration and
163
+ grant therefore live in the **same** database, and the grant can resolve the script id from a
164
+ **hardcoded v4 UUID literal** via a same-database lookup — **no id needs pinning and no
165
+ cross-team coordination is required.** A `Core.RecordScripts` registration puts the id in
166
+ `Core` while the grant lives in the client DB and links by numeric id, forcing exactly the
167
+ impossible cross-tenant read described above.
168
+ 2. **api2 reads both from `DB_CLIENT`.** In `api2/Component/Api/V2/V2.php`, the
169
+ `lookupCustomRecordScriptsByRecordIdAndMethodAndRoute` map is built from
170
+ `SELECT … FROM CustomRecordScripts` (~line 8613) and authorization goes through
171
+ `AclCustomRecordScripts` (~line 5919). **`Core.RecordScripts` are checked FIRST, with a
172
+ fallback to custom** (~line 5906) — so a custom script cannot shadow a global one, and a
173
+ global one always wins.
174
+ 3. **It is semantically correct.** The method is declared on the *client* model, not the base
175
+ model. Registering it in `Core` would advertise the route to **every** tenant, including
176
+ tenants whose model has no such method.
177
+
178
+ **Authoring notes from the first use:**
179
+ - `recordId` is written as the **literal** (191 for Entitlements) per team convention —
180
+ `Core.Records` ids **are** deliberately seeded identically in every environment
181
+ (Addresses 13, Sales orders 14, Entitlements 191, Subscriptions 253). This is safe;
182
+ `Core.RecordScripts` ids are **not** (they have drifted per environment).
183
+ - **Grant `Base` only, resolved by NAME** — role ids differ per client.
184
+ - **Do NOT grant `Public`.** A warranty-availability endpoint reveals whether a warranty exists
185
+ at an arbitrary address; unauthenticated probing must be prevented.
186
+ - Verified by applying the file **twice** to two environments: exactly one script row and one
187
+ grant row each.
188
+
139
189
  ## Data source note (core DB hosts/credentials)
140
190
 
141
191
  The core cluster hosts and credentials this endpoint reads through are **not** documented here;
@@ -149,6 +199,12 @@ an `AclRecordScripts` grant in each client DB.
149
199
 
150
200
  ## Gotchas / known issues
151
201
 
202
+ - **The response key is the ROUTE VERBATIM, not the `phpMethod`.** A script registered with
203
+ route `warranty-availability` and `phpMethod` `warrantyAvailability` returns
204
+ **`data.entitlements["warranty-availability"]`** — *not* `data.entitlements.warrantyAvailability`.
205
+ The sibling `addresses/validateAddress` reader looks camelCase only because **that script's
206
+ route is itself `validateAddress`.** Reading the wrong key yields `undefined` (and, in a
207
+ fail-closed consumer, correctly denies) — this cost a debug cycle in beta.
152
208
  - **No bound params in `_Query`.** Sanitize with `\_Database::escape()` (strings) and `(int)`
153
209
  casts (numerics) for every value placed into the query text — query-string args are user
154
210
  input.
@@ -169,6 +225,20 @@ an `AclRecordScripts` grant in each client DB.
169
225
 
170
226
  ## Change history
171
227
 
228
+ - 2026-07-31 — TRUE-80487: added the **reusable client-side `CustomRecordScript` pattern** for a
229
+ scripted API declared on a client model — `CustomRecordScripts` + `AclCustomRecordScripts` are
230
+ **both** `DB_CLIENT`, so registration and grant land in **one** database and the grant resolves
231
+ the script id from a hardcoded uuid (no id pinning, no cross-team coordination); api2 reads both
232
+ from `DB_CLIENT` (`V2.php` ~8613 / ~5919) and checks `Core.RecordScripts` **first** with a
233
+ fallback to custom (~5906). Recorded **why the existing `Core.RecordScripts` + `Client_*`
234
+ `AclRecordScripts` convention is unrunnable** for a client-scoped script
235
+ (`AclRecordScripts.recordScriptId` links by numeric id with no uuid reference → forced
236
+ cross-cluster read; `Client/2026-07-16c - AddressValidateScriptAcl.sql` silently no-ops, script
237
+ 28 has zero grants in `Client_Rate`). Also recorded that the response key is the **route
238
+ verbatim** (`data.entitlements["warranty-availability"]`, not the camelCase `phpMethod`), that
239
+ `Core.Records` ids are environment-stable literals while `Core.RecordScripts` ids have drifted,
240
+ and the grant-Base-by-name / never-Public guidance. First use:
241
+ `dbchanges2/Client_Rate/2026-07-31a - WarrantyAvailabilityCustomRecordScript.sql`. (mhammontree)
172
242
  - 2026-07-24 — Refined the engine contract from productionizing the sprint dashboard (verified
173
243
  in `V2.php` + `_underscore/Query.php`): the return value **nests under the route-segment key**
174
244
  (`data[<route>]`, not flat — consumers must unwrap); there is **no current-sprint/context
@@ -6,12 +6,16 @@ project: API
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-07-28
9
+ updated: 2026-07-31
10
10
  owners: ["jcardinal", "mhammontree"]
11
11
  files:
12
12
  - api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh
13
13
  - api2/ebs/register_instance_to_shared_application_load_balancer.php
14
- related: []
14
+ - api2/.platform/hooks/prebuild/git.sh
15
+ - api2/.ebextensions/git.php
16
+ - api2/.ebextensions/git.sandbox-dev.json
17
+ related:
18
+ - ../../../standards/frontend-deploy.md
15
19
  ---
16
20
 
17
21
  ## Summary
@@ -40,6 +44,27 @@ app-repo `_<env>` branches.
40
44
  > this env; you must push it to **`_sandbox-dev`**. Always confirm the exact env↔branch mapping in the
41
45
  > env's `git.<env>.json` before assuming `_<env>` applies.
42
46
 
47
+ ### "beta" is a DIFFERENT git branch per repo — the full map (verified 2026-07-31)
48
+
49
+ The word *beta* names an **environment**, not a branch, and each tier resolves it differently.
50
+ Getting this wrong cost **two wasted deploys** in one session (TRUE-80487). Confirm the tier you
51
+ are actually changing before you merge anything:
52
+
53
+ | Tier | What "beta" is | Branch actually built | Notes |
54
+ |---|---|---|---|
55
+ | `api2` / `_underscore` | EB env **`API-Sandbox-Dev`** behind `api.beta.togahub.com` | `_underscore` **`_sandbox-dev`** (per `.ebextensions/git.sandbox-dev.json`) | **Not `_beta`.** Database environment is **`dev-sandbox`**. |
56
+ | `toga2-view` | AWS Amplify target **named "production"** | git branch **`_beta`**, bundling `.env.beta` (`VITE_API=https://api.beta.togahub.com/v2`) | The target *name* is misleading; proven by a string present only in `_beta` appearing in the deployed UI and absent from `_production`. |
57
+
58
+ - **`Client_Rate` exists in `prod`, `dev-sandbox` and `client-sandbox` — NOT in `client-beta`.** So
59
+ "the beta database" is ambiguous too; name the concrete DB environment.
60
+ - **`_underscore` is NOT vendored into `api2`.** `.platform/hooks/prebuild/git.sh` +
61
+ `.ebextensions/git.php` clone it at build time (branch = the per-env json's `branch`, else
62
+ `_<environment>`). Consequence: **a framework-only change requires REDEPLOYING THE CONSUMING
63
+ TIER even when `api2` itself has zero changes** — an instance **restart will not pick it up**,
64
+ because the clone happens in prebuild.
65
+ - **`api2` has no deploy workflow.** Its only GitHub Action is the `TRUE-#####:` commit-message
66
+ linter, so **EB deploys are manual**. Do not wait for CI to ship a framework change.
67
+
43
68
  ### Debugging a beta env without Sentry — `Logs_<Client>.Api`
44
69
  `Logs_<Client>.Api` is a **full server-side request log**: `queryString`, request/response payloads,
45
70
  `direction` (`IN`/`OUT`), `instanceId`, and `executionTime`. It is the reliable way to see what a
@@ -139,6 +164,14 @@ aws codeconnections get-connection --connection-arn "<CONN_ARN>" --region "$REGI
139
164
  ```
140
165
 
141
166
  ## Change history
167
+ - 2026-07-31 — TRUE-80487: added the **full cross-tier "beta" environment map** after it cost two
168
+ wasted deploys. `api.beta.togahub.com` is EB env **`API-Sandbox-Dev`** on the **`dev-sandbox`**
169
+ database, cloning `_underscore` **`_sandbox-dev`**; `toga2-view`'s Amplify target **named
170
+ "production"** actually builds branch **`_beta`** with `.env.beta` pointing at that API. Recorded
171
+ that `Client_Rate` exists in prod/dev-sandbox/client-sandbox but **not** client-beta, that
172
+ `_underscore` is cloned in **prebuild** (so a framework-only change needs the consuming tier
173
+ **redeployed**, not restarted), and that `api2` has **no deploy workflow** — EB deploys are
174
+ manual. (mhammontree)
142
175
  - 2026-07-28 — Manual LB re-registration is now automated on **non-production** tiers carrying the `.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh` hook pair (api2 already had it; ported to worker2 this session). Linked the new worker2 feature doc as the reference implementation and flagged that api2's copy has known security defects. (jcardinal)
143
176
  - 2026-07-27 — Noted that not every EB env pulls `_underscore` from its `_<env>` branch: **`API-Sandbox-Dev`** re-clones `_underscore` from the branch in `.ebextensions/git.sandbox-dev.json` (**`_sandbox-dev`**), overwriting the pipeline copy — so a `_beta` merge doesn't reach it. Added the `Logs_<Client>.Api` server-side request-log note as the reliable way to debug a beta env without Sentry (TRUE-79533). (mhammontree)
144
177
  - 2026-07-14 — Documented the branch model: app repos (`_underscore`/`api2`/`toga2-supply`) deploy from long-lived `_beta`/`_production` branches (not `_main`); api2 pulls `_underscore`'s `_<env>` branch at EB build; `dbchanges2` has only `_main` and its migrations are applied per-env by the team process (not by a code deploy). (mhammontree)
@@ -467,6 +467,27 @@ rule resumes.
467
467
  earlier `2026-06-25c` / `2026-06-29c` / `2026-06-25d` surface seeds **likely share the same
468
468
  anti-pattern** and need auditing. Core→Core seeds are fine (same cluster). Also still open:
469
469
  re-seeding NYCHH/Prudential/SPGlobal (deferred).
470
+ - **✅ AUDIT DONE (2026-07-31, TRUE-80487) — and the failure is WORSE, but the fix is CHEAPER
471
+ than assumed.** A full sweep of `dbchanges2` found **24 of 505 non-`HISTORIC` `.sql` files**
472
+ referencing a database outside their folder's tenant (comment-only mentions excluded): **13 in
473
+ `Client/`** — including `2026-06-03 BLANK_CLIENT_DATABASE`, the **new-client template** — **3
474
+ in `Client_Rate/`**, **2 in `Core/` reaching into `Client.*`**, **2 in `Team/`**, plus
475
+ `Client_Adyen`, `Client_Compass`, `Client_Pcmaticb2b`, `Client_Quad`.
476
+ - **PROVEN broken, not theoretical:** `Client/2026-07-16c - AddressValidateScriptAcl.sql`
477
+ silently no-ops — Core script 28 (`addresses/validate`) has **zero** `AclRecordScripts` rows
478
+ in `Client_Rate`.
479
+ - **The root cause is not only the production tenant split.** A literal `Core.<Table>` does
480
+ **not** resolve to the 2.0 core on a developer machine either: **locally the 2.0 core is
481
+ `Core_2`, while `Core` is the *1.0* database**, which has no `RecordScripts` and no
482
+ `RecordFields` tables. So the statement fails **locally too** — `SET @var` stays NULL and the
483
+ `NOT EXISTS`-guarded INSERT silently inserts nothing. **Both environments break, by different
484
+ mechanisms.** This makes "it worked locally" impossible as a defense.
485
+ - **The fix is a literals swap, not a schema change.** Follow the prod-safe pattern already
486
+ documented at `Client/2026-06-23b - ItemTranslationsAcl.sql` (jcardinal): hardcode Core ids as
487
+ **literals**, resolve only client-local ids by name. And for a **client-specific scripted
488
+ API**, moving the registration to client-side `CustomRecordScripts` removes the cross-tenant
489
+ need **entirely** — see
490
+ [Record Scripts → client-side CustomRecordScripts](../../api2/features/record-scripts.md).
470
491
  - **A `VISIBILITY_RULE`/`ENABLED_RULE` override row can exist in the DB with the CORRECT `c_longValue`
471
492
  yet be silently ignored because its `attribute` coerced to `''`.** The `SurfaceOverrides.attribute`
472
493
  ENUM must be widened per client (`Client/2026-07-15 - SurfaceOverridesAttributeRuleValues.sql`)
@@ -500,6 +521,17 @@ rule resumes.
500
521
  override is added). **Open follow-up.**
501
522
 
502
523
  ## Change history
524
+ - 2026-07-31 — **Closed the "audit the cross-DB surface seeds" open item** (TRUE-80487): swept all
525
+ 505 non-`HISTORIC` `.sql` files and found **24** cross-tenant references (13 in `Client/`,
526
+ including the `BLANK_CLIENT_DATABASE` new-client template; 3 in `Client_Rate/`; 2 in `Core/`
527
+ reaching into `Client.*`; 2 in `Team/`; plus Adyen/Compass/Pcmaticb2b/Quad). Confirmed one
528
+ **proven-broken** instance (`Client/2026-07-16c - AddressValidateScriptAcl.sql` → Core script 28
529
+ has zero `AclRecordScripts` in `Client_Rate`) and recorded the **second, previously unknown
530
+ failure mechanism**: locally `Core` is the **1.0** database (the 2.0 core is `Core_2`), so a
531
+ literal `Core.<Table>` fails in local dev too — both environments break, by different causes.
532
+ Noted the remediation is cheaper than this doc assumed (hardcode Core ids as literals per
533
+ `Client/2026-06-23b`, or move a client-scoped scripted API to client-side `CustomRecordScripts`),
534
+ not a schema change. (mhammontree)
503
535
  - 2026-07-23 — Repointed the Order Details **"PO Number"** detail field (all clients, Core seed).
504
536
  `Core/2026-07-23a - PoNumberDetailFieldValueKey.sql` updates the `SurfaceElements` row (uuid
505
537
  `d45c51d8-8211-11f1-bfa7-a30f63c3a801`, label `salesOrder.field.poNumber`) `config` from
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-30
9
+ updated: 2026-08-01
10
10
  owners: ["jcardinal"]
11
11
  files:
12
12
  - worker2/Worker/Infrastructure/Errors.php
@@ -71,7 +71,7 @@ range contains today.
71
71
 
72
72
  ### Recurrence (episodes)
73
73
 
74
- One `IssueClickupTasks` row per ClickUp **episode**. Closing the task fires a webhook that stamps
74
+ One `IssueClickupTask` row per ClickUp **episode**. Closing the task fires a webhook that stamps
75
75
  `dtResolved` and increments `recurrenceCount`. A later occurrence opens a **new** task linked to
76
76
  its predecessors via `POST /task/{id}/link/{links_to}` — a **link, not a dependency**; a
77
77
  dependency would *block* the task. A cooldown prevents recreating a task 60 seconds after
@@ -85,7 +85,7 @@ which is safe only because the automation never sets status itself.
85
85
 
86
86
  ### Area-level owner learning (observation only)
87
87
 
88
- `IssueAreaOwners` learns an owner after **5 in a row** and ships **observation-only** — a hint in
88
+ `IssueAreaOwner` learns an owner after **5 in a row** and ships **observation-only** — a hint in
89
89
  the task body, never an auto-assignment. Misassignment is expensive: a few wrong tickets and
90
90
  developers stop trusting the queue. Learning is per **area**, not per issue, because one issue
91
91
  rarely recurs enough to learn from before it is fixed.
@@ -119,7 +119,7 @@ recipients away** after a quiet month.
119
119
 
120
120
  - **The working-set query must be a UNION, not an OR.** It is a UNION of three separately
121
121
  indexable branches. MySQL will **not** `index_merge` across an `OR` when one branch is a
122
- correlated `EXISTS`, so the OR form **full-scans `Issues` every 60 seconds, forever**.
122
+ correlated `EXISTS`, so the OR form **full-scans `Issue` every 60 seconds, forever**.
123
123
  - **`INSERT ... SELECT ... WHERE NOT EXISTS` is not a concurrency guard** under REPEATABLE READ.
124
124
  The open-episode guard is a UNIQUE key on a VIRTUAL generated column, and the code **claims
125
125
  the episode before calling ClickUp** so a losing run fails before creating a duplicate
@@ -131,7 +131,7 @@ recipients away** after a quiet month.
131
131
  - **Business email bodies/subjects must not carry `errorMessage` or `trace`.** Both are seeded
132
132
  once from whichever client created the Issue and are never re-scoped. An uncurated business
133
133
  issue gets a **neutral** subject rather than another client's raw error text.
134
- - **`IssueEmailAddresses.clientId = 0` means "all clients"** and requires an explicit
134
+ - **`IssueEmailAddress.clientId = 0` means "all clients"** and requires an explicit
135
135
  confirmation flag in the Tools console; list views render it as **"ALL CLIENTS"**.
136
136
  - **The ClickUp webhook has no HMAC signature verification** (pre-existing, unfixed). A forged
137
137
  POST can mark issues acknowledged — freezing the neglect axis — or resolve episodes. That is
@@ -143,6 +143,12 @@ recipients away** after a quiet month.
143
143
 
144
144
  ## Change history
145
145
 
146
+ - 2026-08-01 — Table names updated for the Core Logs **singular** rename (`Issue`, `Event`,
147
+ `IssueFingerprint`, `IssueClickupTask`, `IssueEmailAddress`, `IssueAreaOwner`). Naming-only;
148
+ the cron's behavior is unchanged. See
149
+ [error-reporting-issue-event](../../_underscore/features/error-reporting-issue-event.md).
150
+ (jcardinal)
151
+
146
152
  - 2026-07-30 — Built as part of TRUE-78188: action renamed `SyncWithClickup` → `Escalate`; new
147
153
  `Worker/Clickup/ErrorTask.php`. Two-axis urgency (volume 20/50/100 + neglect 1/4/24) with a
148
154
  `minimumUrgency` floor; fast-up/slow-down with release bands 12/35/70 over 3 windows; a
@@ -58,7 +58,7 @@ Lambda, the EB worker tier + SQS delivery (see [worker2 architecture](../archite
58
58
 
59
59
  worker2 has **two** monitoring patterns and neither writes to the central **`Logs` Issues/Events**
60
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`
61
+ errors/exceptions** that escalate to ClickUp or email (business-routed by `IssueEmailAddress`
62
62
  presence; see the escalation-cron work). **Periodic health-check, integration-health, and
63
63
  data-quality RESULTS do NOT go there** — they belong in the Monitor framework:
64
64
 
@@ -5,7 +5,7 @@ project: _Underscore
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-07-27
8
+ updated: 2026-08-01
9
9
  owners: [jcardinal, mhammontree]
10
10
  files: []
11
11
  related:
@@ -292,6 +292,34 @@ WHERE
292
292
 
293
293
  ### **SQL Naming Conventions**
294
294
 
295
+ #### **Table Names — plural, except the `Logs` cluster**
296
+
297
+ * Table names are **plural**: `Orders`, `Customers`, `PurchaseOrderItems`. This is the default
298
+ and applies to every database on the platform with one exception.
299
+ * **Exception — the shared Core `Logs` database uses SINGULAR table names**: `Issue`, `Event`,
300
+ `IssueFingerprint`, `IssueClickupTask`, `IssueEmailAddress`, `IssueAreaOwner`. The `Logs`
301
+ cluster is named for the *thing being logged*, matching how it is referred to verbally —
302
+ "API Logs", not "APIs Logs"; "the Issue table", not "the Issues table".
303
+ * **When adding a table to `Logs`, name it singular.** Do not "fix" existing `Logs` tables to
304
+ plural, and do not carry the singular form into any other database.
305
+ * Foreign keys are unaffected: a key referencing `Issue` is still `issueId` (the FK rule already
306
+ singularizes).
307
+
308
+ ```
309
+ # Good — non-Logs databases
310
+ Orders
311
+ Customers
312
+ Locations_LocationAttributes
313
+
314
+ # Good — the Logs database only
315
+ Issue
316
+ IssueFingerprint
317
+
318
+ # Bad
319
+ Logs.Issues # Logs tables are singular
320
+ Core.Order # non-Logs tables are plural
321
+ ```
322
+
295
323
  #### **Bridge Tables**
296
324
 
297
325
  * Most bridge tables need to include an underscore between the two tables they are joining. If you are joining a parent with a child table, the parent should come first. Exceptions may be made for inherent child records like `SalesOrderItems` being a child yet also a bridge of `SalesOrders`
@@ -756,3 +784,7 @@ client sending a shallow depth.
756
784
  ### Code Documentation
757
785
 
758
786
  * Ensure code is well-documented with comments and usage examples.
787
+
788
+ ## Change history
789
+
790
+ - 2026-08-01 — Recorded the Logs-cluster singular table-naming exception to the otherwise-plural table convention. (jcardinal)
@@ -18,7 +18,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
18
18
 
19
19
  ## 2.0 framework
20
20
 
21
- - **_underscore** (_Underscore) _(framework core)_ — 40 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
+ - **_underscore** (_Underscore) _(framework core)_ — 42 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 38 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 20 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 3 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -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, dbchanges2/Client_Rate/2026-07-24a - AddressIdFieldPermission.sql |
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 | > **STATUS (2026-07-27): beta-verified end-to-end; promoting to `_production`.** The final blocker > to a correct service-address pin turned out to be a **front | _underscore/Model/Rate/Entitlement.php, _underscore/Model/Client/Entitlement.php, _underscore/Model/Client/Address.php, toga2-view/src/pages/CheckOut/api/checkoutApi.ts, 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 |
11
+ | [Rate Whole Home Warranty Per-Address Purchase Guard](features/whole-home-warranty-purchase-guard.md) | 2.0 | > # ⚠ STATUS (2026-07-31, TRUE-80487) — THE GUARD HAD NEVER EXECUTED, IN ANY ENVIRONMENT > > Everything below the next section described a guard that **never ra | _underscore/Model/Rate/Entitlement.php, _underscore/Model/Client/Entitlement.php, _underscore/Model/Client/Address.php, _underscore/Component/Library/Carriers/Usps/Usps.php, toga2-view/src/pages/CheckOut/api/checkoutApi.ts, toga2-view/src/pages/CheckOut/viewModel/useCheckoutPageViewModel.ts, toga2-view/src/pages/ZipValidation/api/zipValidation.ts, dbchanges2/Client/2026-07-22a - EntitlementServiceAddressId.sql, dbchanges2/Core/2026-07-22a - EntitlementServiceAddressIdField.sql, dbchanges2/Client_Rate/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql, dbchanges2/Client_Rate/2026-07-31a - WarrantyAvailabilityCustomRecordScript.sql, test/@Mark/Rate/verify_wholehome_per_address_guard.php, test/@Mark/Rate/verify_wh_gate_and_unit_dedup.php |
12
12
  | [Rate](profile.md) | 2.0 | Rate is a mortgage/lending client. | |
@@ -6,24 +6,45 @@ project: _Underscore
6
6
  client: rate
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-07-27
9
+ updated: 2026-07-31
10
10
  owners: [mhammontree, tcox]
11
11
  files:
12
12
  - _underscore/Model/Rate/Entitlement.php
13
13
  - _underscore/Model/Client/Entitlement.php
14
14
  - _underscore/Model/Client/Address.php
15
+ - _underscore/Component/Library/Carriers/Usps/Usps.php
15
16
  - toga2-view/src/pages/CheckOut/api/checkoutApi.ts
17
+ - toga2-view/src/pages/CheckOut/viewModel/useCheckoutPageViewModel.ts
18
+ - toga2-view/src/pages/ZipValidation/api/zipValidation.ts
16
19
  - dbchanges2/Client/2026-07-22a - EntitlementServiceAddressId.sql
17
20
  - dbchanges2/Core/2026-07-22a - EntitlementServiceAddressIdField.sql
18
21
  - dbchanges2/Client_Rate/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql
22
+ - dbchanges2/Client_Rate/2026-07-31a - WarrantyAvailabilityCustomRecordScript.sql
19
23
  - test/@Mark/Rate/verify_wholehome_per_address_guard.php
24
+ - test/@Mark/Rate/verify_wh_gate_and_unit_dedup.php
20
25
  related:
21
26
  - clients/rate/profile.md
22
27
  - clients/rate/features/aig-contract-creation.md
23
28
  - ../../../2.0/apps/_underscore/features/address-validation.md
29
+ - ../../../2.0/apps/_underscore/features/usps-dpv-deliverability.md
30
+ - ../../../2.0/apps/_underscore/features/address-uniqueness-normalization.md
31
+ - ../../../2.0/apps/api2/features/record-scripts.md
24
32
  ---
25
33
 
26
- > **STATUS (2026-07-27): beta-verified end-to-end; promoting to `_production`.** The final blocker
34
+ > # ⚠ STATUS (2026-07-31, TRUE-80487) — THE GUARD HAD NEVER EXECUTED, IN ANY ENVIRONMENT
35
+ >
36
+ > Everything below the next section described a guard that **never ran**. `isWholeHomeWarranty()`
37
+ > read `$payload->saleItem->title`, but the real `toga2-view` checkout payload sends
38
+ > `{"saleItem":{"uuid":"…"}}` with **no title** — so `stripos('')` was always false and `prePost`
39
+ > returned **before** the guard on **every** purchase. **The guard's rejection message appears in
40
+ > NO log, in ANY environment, ever.** Live proof: dev-sandbox held **3 active WH entitlements at
41
+ > one address** (ET100057 / ET100067 / ET100073), two of them created **2026-07-27** — the day the
42
+ > feature was marked "beta-verified". Three latent defects, the DPV enforcement, unit-level
43
+ > address normalization, and a server-side availability endpoint all landed on TRUE-80487; read
44
+ > **[What TRUE-80487 changed](#what-true-80487-changed-2026-07-31)** first, then the historical
45
+ > detail below.
46
+ >
47
+ > **STATUS (2026-07-27, historical): beta-verified end-to-end; promoting to `_production`.** The final blocker
27
48
  > to a correct service-address pin turned out to be a **front-end request-`depth` bug**: Rate's
28
49
  > checkout was sending `{depth:-1}` on `POST /entitlements`, so V2 built a **shallow** payload
29
50
  > (`meta.calcDepth 1`) and `postPost` never received the nested
@@ -45,8 +66,9 @@ A customer may hold **one active Whole Home Warranty (WH) per validated address,
45
66
  Entitlements route via a `prePost` interceptor on `_Model_Rate_Entitlement`
46
67
  (`_underscore/Model/Rate/Entitlement.php`, Core.Records recordId **191**, registered PRE/POST).
47
68
 
48
- For **WH purchases only** (WH is identified by the sale-item title containing `"warranty"`,
49
- consistent with `resolvePurchaseProduct`), the guard:
69
+ For **WH purchases only** — WH is identified by **resolving `saleItem.uuid` → `Items`** and
70
+ matching on the **same authority the DB dedup uses** (title keyword **OR** `WH_SALE_ITEM_ID`);
71
+ see [the identification defect](#what-true-80487-changed-2026-07-31) — the guard:
50
72
 
51
73
  1. **Requires a service address** from `$payload->contact->primaryContactAddress->address`.
52
74
  2. **Hard-blocks** the purchase if
@@ -65,6 +87,102 @@ confirmation-email flow.
65
87
 
66
88
  Ticket: TRUE-79533. Business rule owner: PM Paulina.
67
89
 
90
+ ## What TRUE-80487 changed (2026-07-31)
91
+
92
+ ### 1. The guard never fired — payload-shape mismatch on the WH test (FIXED)
93
+
94
+ `isWholeHomeWarranty()` read **`$payload->saleItem->title`**. The real checkout payload is
95
+ **`{"saleItem":{"uuid":"…"}}`** — **no `title`** — so the test was always false and `prePost`
96
+ returned before the guard **in every environment since the feature shipped**.
97
+
98
+ Now the guard **resolves `saleItem.uuid` → `Items`** and matches on the **same authority the DB
99
+ dedup uses**: title keyword **OR** `WH_SALE_ITEM_ID`. It **deliberately does NOT fall back to
100
+ `$payload->name`** — that is freeform client text the dedup never consults, so matching on it
101
+ would make the guard and the dedup disagree.
102
+
103
+ ### 2. Second latent defect in the same path — `state.code` vs `state.uuid` (FIXED)
104
+
105
+ The guard read `$address->state->code`, but the payload sends **`state.uuid`**. **Fixing #1
106
+ alone would have thrown *"A valid service address is required"* on every valid purchase.**
107
+ Added `resolveStateCode()` (prefers `code`, falls back to resolving the `uuid`).
108
+
109
+ Also: the canonical write-back now **only overwrites `state->code` when the payload ALREADY
110
+ carried that key**. Adding a `code` key to a uuid-only nested object is an **`EV-8` risk that
111
+ rejects the whole POST.**
112
+
113
+ ### 3. Address comparison is now unit-level and ZIP5-level
114
+
115
+ See [address-uniqueness-normalization](../../../2.0/apps/_underscore/features/address-uniqueness-normalization.md):
116
+ `normalizeUnitIdentifier()` collapses `Suite 500` / `STE 500` / `#500` / `Apt 500` / `500` to
117
+ `500` (an **empty** unit means the whole building and conflicts with **any** unit at that street
118
+ address, in both directions), and `comparableZip()` compares the first 5 digits because USPS
119
+ returns **ZIP+4** (`60503-7832`) while every legacy `Addresses` row holds 5 digits — an exact
120
+ comparison had been missing **every legacy row**. SQL narrows on
121
+ `line1 + city + ZIP5 + state`; the unit comparison runs in **PHP** over up to
122
+ `DEDUP_CANDIDATE_LIMIT = 500` candidates (with an `error_log` canary).
123
+
124
+ ### 4. USPS DPV is now enforced — this is what implements the coverage rule
125
+
126
+ See [usps-dpv-deliverability](../../../2.0/apps/_underscore/features/usps-dpv-deliverability.md).
127
+ The waterfall was **discarding** DPV, and USPS returns **HTTP 200 with a populated address even
128
+ when the unit is missing or invented** (it echoes `UNIT 200` back verbatim), so success alone was
129
+ insufficient. Mapping: **`Y`** proceed / **`D`** reject *unit required* / **`S`** reject *unit not
130
+ verified* / **`N`** reject / **unknown proceeds** (FedEx/UPS report no DPV; failing closed would
131
+ block every purchase during a USPS outage, and the dedup still prevents duplicates).
132
+
133
+ **This is what implements the business rule the developer specified: coverage is per UNIT when
134
+ the building is subdivided, and per BUILDING when it is not.**
135
+
136
+ ### 5. `evaluateWholeHomeAddress()` is the SINGLE authority, exposed as an API
137
+
138
+ One method performs: **completeness → carrier validation → DPV verdict → canonical adoption →
139
+ global duplicate check**. `prePost()` **throws** on it; the new **`warrantyAvailability`**
140
+ scripted API returns **the same verdict as data**. They cannot drift, because it is one method.
141
+
142
+ Machine-readable reasons: `UNAVAILABLE_INCOMPLETE_ADDRESS`, `UNAVAILABLE_ADDRESS_UNVERIFIED`,
143
+ `UNAVAILABLE_UNIT_REQUIRED`, `UNAVAILABLE_UNIT_INVALID`, `UNAVAILABLE_DUPLICATE_WARRANTY`, plus
144
+ the canonical address.
145
+
146
+ **Why it had to move server-side.** The availability check previously ran in the React app
147
+ against **`useActiveServices(borrowerId)`** — a query scoped to the **logged-in borrower** — and
148
+ could only see a warranty whose `serviceAddressId` had been **pinned**. **A warranty is per
149
+ DWELLING, not per customer**, so it reported "eligible" whenever the existing warranty belonged
150
+ to **someone else** or was **unpinned**; the customer then **paid through PayPal** and only then
151
+ hit the server guard. Widening the FE query is **not** an option (cross-borrower data leak), so
152
+ the question must be answered server-side. The response is a **boolean + generic reason**,
153
+ leaking nothing about who holds an existing warranty. `toga2-view`'s local dedup in
154
+ `pages/ZipValidation/*` was **DELETED, not patched**, and the page **fails CLOSED** if the call
155
+ errors.
156
+
157
+ ### 6. Registration: a client-side `CustomRecordScript` (reusable pattern)
158
+
159
+ `dbchanges2/Client_Rate/2026-07-31a - WarrantyAvailabilityCustomRecordScript.sql` registers the
160
+ endpoint in the **client** database, **not** `Core.RecordScripts` — see
161
+ [Record Scripts → client-side CustomRecordScripts](../../../2.0/apps/api2/features/record-scripts.md)
162
+ for the full rationale (both tables are `DB_CLIENT`, so the grant resolves the script id from a
163
+ hardcoded uuid in **one** database; a `Core` registration would force an impossible cross-tenant
164
+ read). `recordId` is the literal **191**. Granted to **`Base` only, resolved by name** (role ids
165
+ differ per client) and **never `Public`** — the endpoint reveals whether a warranty exists at an
166
+ arbitrary address, so unauthenticated probing must be prevented. Verified by applying it twice to
167
+ two environments: exactly one script row + one grant row.
168
+
169
+ **Read the response at `data.entitlements["warranty-availability"]`** — the route verbatim, not
170
+ the camelCase `phpMethod`. Reading `data.entitlements.warrantyAvailability` yields `undefined`
171
+ and (correctly) fails closed; this cost a debug cycle in beta.
172
+ (`toga2-view/src/pages/ZipValidation/api/zipValidation.ts`)
173
+
174
+ ### 7. Why the "works in beta, fails in production" report was FALSE
175
+
176
+ Same code, same migrations, **byte-identical `ApiPayloadInterceptors` rows** in both environments
177
+ (verified). The backend guard had **never run anywhere**. Beta only *appeared* correct because
178
+ the **frontend** check happened to work there: every WH entitlement in beta belongs to a
179
+ **single contact (`contactId 8`)** and all of its data was created **after** the pin shipped —
180
+ satisfying both of the FE check's hidden preconditions (**same borrower**, **already pinned**).
181
+ Production satisfied **neither**.
182
+
183
+ > **Verification lesson (general):** *beta passing proves nothing if the dataset cannot exercise
184
+ > the failing path.* Test as a **different borrower** and against **legacy / unpinned** data.
185
+
68
186
  ## Business rules (decided this session — Mark / Paulina)
69
187
 
70
188
  - **Invalid address → HARD BLOCK.** An address that fails carrier validation blocks the
@@ -72,8 +190,18 @@ Ticket: TRUE-79533. Business rule owner: PM Paulina.
72
190
  - **Uniqueness is GLOBAL per address**, not per-borrower — a second active WH on the same
73
191
  physical address is rejected regardless of which borrower buys it.
74
192
  - **Address line2 distinguishes units.** A landlord's apartment units are distinct addresses:
75
- 2A / 2B / 2C = three separate WHs. Uniqueness keys on the full normalized address including
76
- line2.
193
+ 2A / 2B / 2C = three separate WHs. Uniqueness keys on the **normalized unit identifier** of
194
+ line2 (see [#3 above](#3-address-comparison-is-now-unit-level-and-zip5-level)) — designators are
195
+ discarded, and an **empty** unit means the whole building and conflicts with every unit.
196
+ - **Coverage is per UNIT when the building is subdivided, per BUILDING when it is not** — and the
197
+ only signal that distinguishes those cases is **USPS DPV** (`D` = secondary unit required). See
198
+ [#4 above](#4-usps-dpv-is-now-enforced--this-is-what-implements-the-coverage-rule).
199
+ - **`dateEnd` stays OUT of the dedup predicate (DECIDED 2026-07-31).** The active test is
200
+ `Subscriptions.isActive = 1 AND dateCancelled IS NULL`. Adding `dateEnd` would let **real**
201
+ duplicates through whenever a renewal has not rolled `dateEnd` forward — and renewals are not
202
+ advancing it: **production entitlement 40 (ET100014, Kimberly Stearns) has `dateEnd`
203
+ 2026-06-01 yet was charged 2026-07-01.** That is a **separate defect worth its own ticket**;
204
+ `dateEnd` is currently unreliable as an indicator of anything.
77
205
  - **Persist the validated address ON the entitlement** (via the `serviceAddressId` FK) rather
78
206
  than deriving it from the fragile contact-primary-address path. **No backfill ships** —
79
207
  production launches with no pre-existing WH entitlements, so there is nothing to backfill (the
@@ -223,15 +351,66 @@ endpoint mechanics.
223
351
 
224
352
  A self-contained backend verification script lives at
225
353
  `test/@Mark/Rate/verify_wholehome_per_address_guard.php` (run via PHP CLI with `ENVIRONMENT`
226
- set, pinned to the real migrated **`Client_Rate`** schema — see the
227
- [2.0 backend-testing standard](../../../2.0/standards/backend-testing.md)). It applies
354
+ set, pinned to the real migrated **`Client_Rate`** schema — there is no PHPUnit here, so
355
+ backend verification is a standalone CLI script pinned to a real client database). It applies
228
356
  `c_serviceAddressId` idempotently, seeds a token-tagged scratch WH graph (2 addresses / 2 items /
229
357
  2 active subscriptions / 2 entitlements), then exercises the pure helpers, the dedup matrix (via
230
358
  reflection on the private methods), and the `prePost` branches, cleaning up in a `finally`. The
231
359
  live carrier waterfall is opt-in via `RUN_LIVE=1` (defaults off). Verified **18/18 pass**.
232
360
 
361
+ > **⚠ That script passes 18/18 against a payload shape production NEVER SENDS** (`saleItem.title`
362
+ > present, `state.code` present). **That is precisely why all three defects survived review.**
363
+ > The replacement — **`test/@Mark/Rate/verify_wh_gate_and_unit_dedup.php` (77 assertions, all
364
+ > passing)** — asserts against the **real uuid-only `saleItem` / `state` payload shape**, and its
365
+ > `RUN_LIVE=1` section asserts the four live USPS DPV verdicts and that `prePost` **throws** for
366
+ > `D` and `S`. **Verification scripts must use the real payload shape.**
367
+
368
+ ## Production + beta data cleanup (2026-07-31, done by the developer, query-verified)
369
+
370
+ **Production** — three active WH warranties are now pinned and validated: **43 (ET100017) →
371
+ address 9 Naperville**, **40 (ET100014) → address 6 Nashua**, **38 (ET100012) → address 4
372
+ Wailuku**. Subscription **22 (ET100015, Daniel Moran)** closed out (`isActive = 0`,
373
+ `dateCancelled` 2026-06-03). Address **7** city corrected from `"60657"` to `"Chicago"`. The
374
+ **both-or-neither invariant** (`Entitlements.serviceAddressId` set ⟺ `Addresses.isValidated = 1`)
375
+ holds on all **nine** rows.
376
+
377
+ **Beta** — 23 subscriptions deactivated, leaving **0 active unpinned WH entitlements**.
378
+ Previously **22 unpinned entitlements all belonged to `contactId 8`** and collapsed onto **ONE**
379
+ address (`1864 HIGH GROVE LN STE 500`) through the `COALESCE(serviceAddressId, contact primary
380
+ address)` fallback — which is what made beta's data look coherent.
381
+
382
+ > **Near-miss worth remembering:** beta entitlements **100 / 105 / 106** were initially
383
+ > misidentified as duplicates from a query that selected only `line1 + zip`. They are in fact
384
+ > **DISTINCT units** (`STE 1900` / `# 4` / `STE 500`) and must **NOT** be deleted.
385
+ > **Always fetch `line2` before judging address duplication.**
386
+
233
387
  ## Gotchas / known issues
234
388
 
389
+ - **Apartment / property-type eligibility is CLIENT-SIDE ONLY and bypassable.** The property
390
+ questionnaire's `propertyType` lives in **router state / `sessionStorage`**
391
+ (`toga2-view/src/pages/CheckOut/viewModel/useCheckoutPageViewModel.ts`) and is **never sent to
392
+ the API** — the purchase payload carries no `propertyType` / `isEligible`. `prePost` requires
393
+ `line1`/`city`/`state`/`zip` but **not `line2`**, and the `requireUnitNumber` bounce-back is
394
+ **frontend navigation state only**. So an apartment building could be insured with **no unit at
395
+ single-dwelling pricing**. Live evidence: beta **ET100073** carries `line2` `"STE 500"` on a
396
+ **single-family** home. The **DPV `D`/`S` enforcement closes this server-side.** Note that
397
+ carriers cannot supply dwelling type at all (UPS/FedEx classification is only Residential vs
398
+ Commercial, and an apartment building is *Residential*) — true dwelling type needs property
399
+ data (assessor/parcel or a Melissa/Smarty-class vendor), a **procurement decision, not a code
400
+ one**.
401
+ - **⚠ CORRECTION to the 2026-07-30 session doc.**
402
+ `knowledge/sessions/2026-07-30-TRUE-80487-Debug-Fix-Rate-WH-Warranty-mhammontree.md` records
403
+ *"cross-cluster `Core.*` refs from `Client*/` folders fail in prod — DISPROVEN … the ACL grants
404
+ landed correctly."* **That conclusion is WRONG.** The grants **exist** in production but
405
+ **cannot have been created by those migrations** — `Client/2026-07-16c -
406
+ AddressValidateScriptAcl.sql` silently no-ops, and Core script 28 (`addresses/validate`) has
407
+ **zero** `AclRecordScripts` rows in `Client_Rate`. Do not trust that line. See
408
+ [address-validation](../../../2.0/apps/_underscore/features/address-validation.md) and the
409
+ [dbchanges2 surface-layer audit](../../../2.0/apps/dbchanges2/features/surface-layer-schema.md).
410
+ - **Renewals are not advancing `Subscriptions.dateEnd`** (production entitlement 40 charged a
411
+ month past its `dateEnd`). Separate defect, separate ticket — and the reason `dateEnd` is
412
+ excluded from the dedup predicate.
413
+
235
414
  - **Shared-interceptor merge hazard — TRUE-79533 and TRUE-79251 edit the SAME methods.** Both
236
415
  this guard and the [purchase-email workflow](service-purchase-emails.md) (TRUE-79251) modify the
237
416
  **same** `prePost`/`postPost` interceptors on `_Model_Rate_Entitlement`, and TRUE-79251 also adds
@@ -350,6 +529,33 @@ live carrier waterfall is opt-in via `RUN_LIVE=1` (defaults off). Verified **18/
350
529
  block) — check `isValidated` to see whether the pin fired. (General 2.0 framework lesson.)
351
530
 
352
531
  ## Change history
532
+ - 2026-07-31 — **THE GUARD HAD NEVER EXECUTED IN ANY ENVIRONMENT** (TRUE-80487, follow-up to the
533
+ already-deployed TRUE-79533). `isWholeHomeWarranty()` tested `$payload->saleItem->title` but the
534
+ real checkout payload sends `{"saleItem":{"uuid"}}` with no title, so `prePost` returned before
535
+ the guard on every purchase — its rejection message appears in **no log in any environment**
536
+ (proof: dev-sandbox held 3 active WH entitlements at one address, two created on the very day
537
+ the feature was called "beta-verified"). Fixed by resolving `saleItem.uuid → Items` and matching
538
+ on the same authority as the DB dedup (title keyword OR `WH_SALE_ITEM_ID`; deliberately **not**
539
+ `$payload->name`). Fixed a **second latent defect** in the same path — the guard read
540
+ `$address->state->code` while the payload sends `state.uuid`, so fixing the first alone would
541
+ have thrown "A valid service address is required" on every valid purchase (added
542
+ `resolveStateCode()`; the canonical write-back now only overwrites `state->code` when the payload
543
+ already carried that key, since adding it to a uuid-only node is an `EV-8` risk). Built
544
+ **unit-level + ZIP5 address comparison**, enforced **USPS DPV** (`Y`/`D`/`S`/`N`, unknown
545
+ proceeds) as the per-unit-vs-per-building signal, consolidated everything into the single
546
+ authority **`evaluateWholeHomeAddress()`**, and exposed it as the **`warrantyAvailability`**
547
+ scripted API (registered as a client-side `CustomRecordScript`, `Base`-only, never `Public`) so
548
+ the FE no longer answers a per-dwelling question with a per-borrower query — the old check
549
+ reported "eligible" whenever the existing warranty belonged to another borrower or was unpinned,
550
+ and the customer paid through PayPal before hitting the server guard. Recorded that the
551
+ "works in beta, fails in prod" report was **false** (identical code/migrations; beta only looked
552
+ right because all its WH data belonged to one already-pinned contact), that apartment eligibility
553
+ is **client-side only and bypassable**, that `dateEnd` stays out of the dedup (renewals are not
554
+ advancing it — separate defect), the production/beta data cleanup, and the **correction to the
555
+ 2026-07-30 session doc's "cross-cluster refs DISPROVEN" claim**. New verification script
556
+ `test/@Mark/Rate/verify_wh_gate_and_unit_dedup.php` (77 assertions) asserts the **real** payload
557
+ shape — the old 18/18 script passed against a shape production never sends, which is why all
558
+ three defects survived review. (mhammontree)
353
559
  - 2026-07-27 — **Shipped working on beta end-to-end; status draft→active** (TRUE-79533). Found and
354
560
  fixed the **final** pin failure: the FE was POSTing `{depth:-1}` on `POST /entitlements`, building a
355
561
  shallow V2 payload (`calcDepth 1`) that **starved `postPost`** of the nested
@@ -9,11 +9,12 @@ apps:
9
9
  - worker
10
10
  - worker2
11
11
  - dbchanges2
12
+ - test
12
13
  project: SAML SSO Gateway
13
14
  client: rate
14
15
  type: profile
15
16
  status: active
16
- updated: 2026-07-23
17
+ updated: 2026-07-31
17
18
  owners: ["rgirish", "bala", "mhammontree", "tcox"]
18
19
  files: []
19
20
  related:
@@ -37,7 +37,7 @@ updated: 2026-07-30
37
37
  - **"TRUE-79251 merge damage deleted the WH helper methods."** DISPROVEN. `git diff origin/_beta -- Model/Rate/Entitlement.php` → zero diff. `PASSTHROUGH_KEY_FULFILLMENT_PREFERENCE` (line 32) and all three private helpers (`normalizedField` 107, `isWholeHomeWarranty` 116, `hasActiveWarrantyAtAddress` 137) present.
38
38
  - **"`_underscore` prod wasn't redeployed past the uuid-clobber fix `9c2e4b1c`."** DISPROVEN by the same zero diff + PR #661 merged.
39
39
  - **"`toga2-view` prod still sends `{depth:-1}` and starves `postPost`."** DISPROVEN. `genericApi.ts:54,68` only append `?depth=-1` when `returnPayloadDepth` is explicitly set; no checkout override. Diff vs `_beta` touches only GetSupport files.
40
- - **"Cross-cluster `Core.RecordFields` references from `Client*/` migration folders fail in prod."** DISPROVEN for this case. Despite prod splitting `prod-core` / `prod-client` / `prod-logs` / `prod-archive` onto separate clusters, the ACL grants landed correctly. ~20 long-standing ACL migrations use this same pattern. (Note: the `dbchanges2-cluster-isolation` hook was NOT found under `.claude/hooks/toga/` — separate follow-up, not this bug.)
40
+ - **"Cross-cluster `Core.RecordFields` references from `Client*/` migration folders fail in prod."** **⚠ CORRECTED 2026-07-31 — THIS CONCLUSION WAS WRONG.** The grants *exist* in production but **cannot have been created by those migrations**: `Client/2026-07-16c - AddressValidateScriptAcl.sql` silently no-ops and Core script 28 (`addresses/validate`) has **zero** `AclRecordScripts` rows in `Client_Rate`. A literal `Core.<Table>` also fails **locally**, where `Core` is the **1.0** database (2.0 core is `Core_2`). An audit found 24 such files. See `clients/rate/features/whole-home-warranty-purchase-guard.md` and `2.0/apps/dbchanges2/features/surface-layer-schema.md`. Original (incorrect) text follows: DISPROVEN for this case. Despite prod splitting `prod-core` / `prod-client` / `prod-logs` / `prod-archive` onto separate clusters, the ACL grants landed correctly. ~20 long-standing ACL migrations use this same pattern. (Note: the `dbchanges2-cluster-isolation` hook was NOT found under `.claude/hooks/toga/` — separate follow-up, not this bug.)
41
41
  - **"The interceptor row is missing, inactive, or duplicated in prod."** DISPROVEN — prod rows are identical to dev-sandbox, same ids and uuids.
42
42
  - **"`POST /v2/entitlement-sales-orders` is the prod purchase path (record 257, no interceptor)."** DISPROVEN. It is only a bridge link: payload is `{"entitlement":{"uuid"},"salesOrder":{"uuid"}}`. It creates no entitlement and fires no interceptor.
43
43
  - **"Production simply hasn't processed a purchase since deploy, so there's nothing to debug."** TRUE as a fact (last `POST /v2/entitlements` in prod = 2026-07-10, two × 400 `EV-8`; newest `Entitlements` row 2026-06-15) but WRONG as a conclusion — the developer then reproduced the failure live through the UI. Do not stop at this observation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.489",
3
+ "version": "1.0.491",
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",