toga-ai 1.0.441 → 1.0.442

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: _Underscore
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-07-23
9
+ updated: 2026-07-27
10
10
  owners: ["jcardinal", "rgirish", "mhammontree"]
11
11
  files:
12
12
  - _underscore/_underscore.php
@@ -341,6 +341,15 @@ multi-file UI components (`.php`/`.html`/`.css`/`.js`) invoked as `<_ComponentNa
341
341
  so folder reads always returned null; fixed. **Remaining follow-up (separate ticket):** `__get()`
342
342
  still refetches storage unconditionally, so a dirty read (read-after-assign, pre-save) discards the
343
343
  unsaved value.
344
+ - **In-request reads can't see the transaction's own uncommitted nested writes — resolve just-created
345
+ records from the hydrated `$payload`, not a same-request DB read-back.** A `postPost` interceptor
346
+ runs **inside the still-open request transaction**. A fresh `_Query` SELECT — even one forced onto
347
+ the write host (the api2 V2 engine already disables the read host for the whole create block) — does
348
+ **not** reliably resolve the rows the same transaction just inserted, especially nested/child rows.
349
+ Read the in-memory `$payload` node hydrated by `getFullModelData()` instead. Pairs with the
350
+ lazy-transaction gotcha below. (TRUE-79533: the Rate WH service-address pin — fingerprint of a lost
351
+ pin is `Addresses.isValidated` and `Entitlements.serviceAddressId` both-or-neither. Relevant code:
352
+ `_underscore/Model/Rate/Entitlement.php`, `api2/Component/Api/V2/V2.php::getFullModelData`.)
344
353
  - **`_Database::register()` auto-starts a lazy transaction (since Apr 2 2026, commit `fa7835ed`).** Any code that calls `register()` and then writes to that DB must call `_Database::transactionCommit()` before the request ends — otherwise MySQL silently rolls back all writes when the connection closes. Lazy transactions only materialise on the first write, so read-only callers are unaffected. See `_underscore/Database.php:48`. First discovered when Rate SAML user provisioning silently discarded all new user INSERTs (Jun 2026).
345
354
  - **PHP "Unclosed '{'" parse errors report a MISLEADING line number.** When a `.php` file loaded by the autoloader (`Loader.php`) has a dropped/unbalanced brace, PHP reports `Unclosed '{' on line N` where N is the **outermost `class X {` line** and fails at EOF — NOT at the true location of the missing `}`. Worse, because the file loads lazily via the SPL autoloader, the runtime trace points at the **caller** that triggered the autoload (e.g. a `new _Email()` call site), not the broken file. **Triage rule:** for an "Unclosed '{'" error, the real culprit is a missing `}` somewhere between the reported line and EOF of the file that failed to load — run `php -l <file>` (it reports the EOF line) and scan the whole file. **Merge-conflict resolutions are a common source of a single dropped brace** — review the entire merge, not just the one file the error appears to name. (First hit: production 500 EO-1, Jul 2026 — a `}` dropped from `_Email::send()` during merge `685e4a14` surfaced as a trace pointing at the `new _Email()` caller.)
346
355
 
@@ -368,6 +377,7 @@ multi-file UI components (`.php`/`.html`/`.css`/`.js`) invoked as `<_ComponentNa
368
377
  stack trace) to API consumers. Not yet done — tracked as a follow-up.
369
378
 
370
379
  ## Change history
380
+ - 2026-07-27 — Added the gotcha that **in-request reads cannot see the transaction's own uncommitted nested writes** — a `postPost` interceptor must resolve just-created records from the `getFullModelData`-hydrated `$payload`, not a same-request `_Query` read-back (even on the write host). Found on the Rate WH service-address pin (TRUE-79533). (mhammontree)
371
381
  - 2026-06-11 — Documented lazy transaction gotcha in `_Database::register()` (rgirish)
372
382
  - 2026-06-25 — Added the Surface platform UI presentation/configuration layer (DB-driven UI config replacing `Page::meta()`, CTO-reviewed AGREE-WITH-ADJUSTMENTS) (jcardinal)
373
383
  - 2026-06-29 — Surface made the enforced (un-flagged) presentation path; reserved id blocks renumbered to Records 333–341 / RecordFields 2246–2433 (known seed-vs-provisioned drift). (jcardinal)
@@ -6,8 +6,8 @@ project: API
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-07-23
10
- owners: [jcardinal, bala]
9
+ updated: 2026-07-27
10
+ owners: [jcardinal, bala, mhammontree]
11
11
  files:
12
12
  - api2/Controller/Index.php
13
13
  - api2/Component/Api/V2/V2.php
@@ -162,9 +162,12 @@ subject ≤80 chars, capitalized, no trailing period, **imperative mood** (rejec
162
162
 
163
163
  `Config/production.ini` (and dev configs) contain **plaintext production credentials** —
164
164
  Core/Client/Logs DB passwords, **AWS access key id + secret**, and third-party API keys
165
- (FedEx, UPS, PayPal, NetSuite, Cal.com, AIG, OptimumDesk, VAPI). `.ebextensions/git.json`
166
- carries a **GitHub PAT**. These should be rotated and moved to SSM Parameter Store / EB env
167
- properties. **Flag this if you touch config or deploy.**
165
+ (FedEx, UPS, PayPal, NetSuite, Cal.com, AIG, OptimumDesk, VAPI). The **`.ebextensions/git.*.json`**
166
+ files (per-env — e.g. `git.json`, `git.sandbox-dev.json`) each carry a **plaintext GitHub Personal
167
+ Access Token**, used by the `.platform/hooks/prebuild/git.sh` clone hook to pull `_underscore` at
168
+ build. These must be **rotated** (treat the committed tokens as compromised) and moved to SSM
169
+ Parameter Store / EB env properties. **Flag this if you touch config or deploy.** (Location +
170
+ remediation only — do not record the token value anywhere.)
168
171
 
169
172
  **Follow-up (deferred, separate ticket): raw exception disclosure to clients.** The error paths that
170
173
  surface a caught `\Throwable` (now that Route.php rethrows and the bootstrap guard reports failures)
@@ -188,4 +191,5 @@ internal paths, schema names, and stack frames to API consumers. Sanitize the cl
188
191
  or `execute()`.
189
192
 
190
193
  ## Change history
194
+ - 2026-07-27 — Sharpened the committed-secret note: the plaintext GitHub PAT lives in the per-env **`.ebextensions/git.*.json`** files (used by the `prebuild/git.sh` clone hook to pull `_underscore`), must be rotated and moved to SSM / EB env properties (location + remediation only, no value). (mhammontree)
191
195
  - 2026-07-23 — Documented the now-guarded Core/Logs DB bootstrap in the front controller: the pre-execute block runs before the `execute()` try/catch, the Core Logs schema name is resolved from a `Core.Database` row (`id = CORE_LOGS_DATABASE_ID`) so a name-mismatched local Logs DB reads as missing, and the failure is now wrapped in `try/catch (\Throwable)` returning `INVALID_CONFIGURATION` + Sentry instead of a fatal (guarded no-op rollback, `Database.php:219–226`). Added the deferred raw-getMessage/getTrace client-disclosure follow-up to the Security note. (jcardinal)
@@ -37,7 +37,7 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
37
37
  | **EO-1** | Operation failed — identifier "There is no field called 'X' in the '_Model_Client_Y' model" | The DB column **and** `Core.RecordFields` exist, but the **generated model class** `_underscore/Model/Client/<Name>.php` doesn't declare the field. This is the **4th** requirement beyond the 3-file migration — and most often it's a **cross-repo git branch mismatch** (`_underscore` on a branch whose generated model lacks a field the DB/RecordFields already carry) | Declare the field in the generated model class (`public $field = self::FIELD_*`) and put all related repos (`_underscore`, `api2`, `dbchanges2`, `toga2-supply`) on the **same** feature branch — see the ACL doc's writable-field recipe |
38
38
  | **EO-1** | Operation failed — **surfaced from a PHP warning/notice, not a real op error** (e.g. "Attempt to read property 'id' on bool", undefined variable) | A latent PHP warning escalates to a 500 because **Sentry's `ErrorHandler` in api2 promotes warnings/notices into thrown exceptions** (see diagnosis note 5). The known instance: in `V2.php::processRoutePairs()` an **unresolved route** leaves the local `$record = false`, and the post-processing payload interceptor layer then dereferenced `$record->id` → warning → 500 | Guard before dereferencing an unresolved record. The fix added a guard clause `if (!$record) return [$rawRequestedRouteName => $outData];` **before** the interceptor/logging layer (so a bad route returns a clean envelope, not a fatal), and initializes `$record = false;` at the top of the `foreach ($lookupByRouteNames ...)` loop so the invalid-HTTP-method / null-`$action` branch can't leave `$record` undefined (an undefined-variable warning would itself escalate to a 500) |
39
39
  | **EZ-1** | Unauthorized record/script dispatch | Missing **`AclRecordScripts`** (scripted APIs) or the **`AclRecordPermissions`** four-table chain (records) for the caller's role | Grant `AclRecordScripts` (scripts) or complete the record-CRUD chain |
40
- | **EZ-2** | Field-level authorization denied (READ) | The field is registered (`Core.RecordFields` present → no `EV-8`) but the caller's role has no **`AclFieldPermissions`** grant to **read** it. The read-side counterpart of `EV-9` (write) | Add the `AclFieldPermissions` row for the role (`isWritable=0` if the field is server-written). For a **custom** `c_` field use `AclCustomFieldPermissions` instead — see the ACL doc's standard-vs-custom table |
40
+ | **EZ-2** | Field-level authorization denied (READ) | The field is registered (`Core.RecordFields` present → no `EV-8`) but the caller's role has no **`AclFieldPermissions`** grant to **read** it. The read-side counterpart of `EV-9` (write). **This applies to the `id` field too:** fetching a record by its numeric `id` (`GET /v2/<record>?fields=id,...`) 403s `EZ-2` if `id` has no read grant — the `id` field is ACL-gated like any other, not implicitly readable | Add the `AclFieldPermissions` row for the role (`isWritable=0` if the field is server-written). For a **custom** `c_` field use `AclCustomFieldPermissions` instead — see the ACL doc's standard-vs-custom table |
41
41
  | **EV-5** | Duplicate `transactionId` | The globally-unique `transactionId` was reused | Send a fresh unique `transactionId` per request |
42
42
  | **EV-12** | Record's parent not synced | (Fulfill & Ship) POST `/item-fulfillments` when the Sales Order isn't synced into Toga yet | `GET /sales-orders/syncNetsuiteSalesOrder?netsuiteInternalSalesOrderId=<id>` first |
43
43
 
@@ -81,6 +81,10 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
81
81
 
82
82
  ## Change history
83
83
 
84
+ - 2026-07-27 — TRUE-79533: noted the **`id`-field** case of `EZ-2` — fetching a record by its numeric
85
+ `id` (`GET /v2/addresses?fields=id,...`) 403s `EZ-2` when `id` has no `AclFieldPermissions` read
86
+ grant; the `id` field is ACL-gated like any field. Found fixing Rate's service-card address fetch
87
+ (`dbchanges2/Client_Rate/2026-07-24a - AddressIdFieldPermission.sql`). (mhammontree)
84
88
  - 2026-07-24 — Added a second **`EO-1`** case: a latent PHP **warning** (unresolved route →
85
89
  `$record->id` on `bool` in `V2.php::processRoutePairs()`, or an undefined loop variable)
86
90
  escalates to an HTTP 500 because api2's Sentry `ErrorHandler` promotes warnings/notices into
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-07-14
9
+ updated: 2026-07-27
10
10
  owners: ["jcardinal", "mhammontree"]
11
11
  files: []
12
12
  related: []
@@ -30,6 +30,21 @@ the team's process**, decoupled from the app branches; a code deploy does not ru
30
30
  full feature promotion to an env = apply the `dbchanges2` migrations to that env **and** deploy the
31
31
  app-repo `_<env>` branches.
32
32
 
33
+ > **Not every EB env pulls `_underscore` from its `_<env>` branch — check `git.<env>.json`.** The
34
+ > beta env **`API-Sandbox-Dev`** does **not** deploy `_underscore` from `_beta`. Its
35
+ > `.platform/hooks/prebuild/git.sh` `rm -Rf`s and **re-clones `_underscore` from the branch named in
36
+ > `.ebextensions/git.<env>.json`** — for this env, `git.sandbox-dev.json` → branch **`_sandbox-dev`** —
37
+ > **overwriting the CodePipeline-supplied copy.** So merging a change into `_beta` does nothing for
38
+ > this env; you must push it to **`_sandbox-dev`**. Always confirm the exact env↔branch mapping in the
39
+ > env's `git.<env>.json` before assuming `_<env>` applies.
40
+
41
+ ### Debugging a beta env without Sentry — `Logs_<Client>.Api`
42
+ `Logs_<Client>.Api` is a **full server-side request log**: `queryString`, request/response payloads,
43
+ `direction` (`IN`/`OUT`), `instanceId`, and `executionTime`. It is the reliable way to see what a
44
+ beta env actually received and returned when Sentry isn't wired up. (The Rate AIG-contract monitor
45
+ scans this same `Logs_Rate.Api` for failed outbound calls — see
46
+ `clients/rate/features/aig-contract-creation.md`.)
47
+
33
48
  ## Steps
34
49
  1. A push to the watched branch (e.g. `_stage`) triggers the pipeline.
35
50
  2. The Source stage uses `CodeStarSourceConnection` actions, each referencing:
@@ -113,6 +128,7 @@ aws codeconnections get-connection --connection-arn "<CONN_ARN>" --region "$REGI
113
128
  ```
114
129
 
115
130
  ## Change history
131
+ - 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)
116
132
  - 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)
117
133
  - 2026-06-18 — Added two EB-instance gotchas surfaced during the TOGa Supply beta/prod label deploys: (1) terminated instances must be **manually re-registered** as LB targets (we don't pay for auto-registration) — until then the new code never serves traffic and looks like deploy-lag; (2) on-instance `composer require` stopgap syntax (no space after the colon, fix root cause by committing `composer.lock`). (mhammontree)
118
134
  - 2026-06-16 — Documented after a pipeline (API-QC-Security, account 975050298201) failed every
@@ -5,8 +5,8 @@ project: _Underscore
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-06-15
9
- owners: [jcardinal]
8
+ updated: 2026-07-27
9
+ owners: [jcardinal, mhammontree]
10
10
  files: []
11
11
  related:
12
12
  - ../apps/_underscore/architecture.md
@@ -542,6 +542,16 @@ $c = 'Hello ' . $world;
542
542
 
543
543
  * Always use exceptions to handle unexpected conditions.
544
544
 
545
+ ### Interceptor / model client-error rejections → throw `_Exception_Validation`, not `_Exception`
546
+
547
+ A hard-block raised inside a pre/post interceptor (or model op) to reject a **bad client request**
548
+ must throw **`_Exception_Validation`** (`_underscore/Exception/Validation.php`), which
549
+ `api2/Controller/Index.php` (≈lines 204–222) maps to `DEFINED_MESSAGE_ERROR_BAD_REQUEST` →
550
+ **HTTP 400** with the message carried in the response envelope (so the front end can drive a
551
+ toaster). Throwing a plain `_Exception` yields an **HTTP 500 + stack trace** — wrong for a
552
+ client-input rejection and an information leak. First applied: the three Rate WH hard-blocks
553
+ (missing / invalid / duplicate service address), TRUE-79533.
554
+
545
555
  ### Comprehensive Error Handling
546
556
 
547
557
  * Implement comprehensive error handling to gracefully manage exceptions and provide meaningful error messages.
@@ -658,6 +668,26 @@ public static function prePut(&$api, &$payload) {
658
668
  }
659
669
  ```
660
670
 
671
+ #### Interceptor payloads are bounded by request `depth` — a shallow request silently starves a POST interceptor
672
+
673
+ A `POST` payload interceptor (`postPost`) only receives the nested records the **built response
674
+ payload** includes. The V2 engine builds that payload to the request's `depth`/`calcDepth`; a low or
675
+ `-1` `depth` yields a shallow payload (`meta.calcDepth 1`) and any nested node the interceptor reads
676
+ (e.g. `contact.primaryContactAddress.address`) is **absent** — so interceptor logic that depends on
677
+ it silently no-ops. Because a deep-payload dev/manual request passes, this fails **only** for the
678
+ client sending a shallow depth.
679
+
680
+ - **`ApiPayloadInterceptors.minDepth` is the intended floor** (defaults to 3 when NULL; an interceptor
681
+ row can raise it — e.g. the entitlements `postPost` row = 5). **Known latent bug (needs its own
682
+ ticket):** for a `depth=-1` request the clamp did **not** actually deepen the payload — the `WO-1`
683
+ warning logged *"raised to minDepth 5"* while `calcDepth` stayed 1. Until fixed, do not rely on
684
+ `minDepth` to guarantee an interceptor sees its nested nodes.
685
+ - **Rule:** any `postPost`/`prePost` that reads nested payload data must not assume it is present —
686
+ guard for the node, and be aware that a client's shallow `depth` (or a front end that hard-codes
687
+ `depth:-1`) will starve it. First hit: the Rate WH `serviceAddressId` pin + AIG contract block
688
+ (TRUE-79533) — both read the same starved node. Relevant code: `api2/Component/Api/V2/V2.php`,
689
+ `api2/Controller/Index.php`.
690
+
661
691
  ### API Versioning
662
692
 
663
693
  * Implement a new version (ie: `/v1`, `/v2`, `/v3`) when a major API framework is introduced to manage the new API framework without breaking existing clients.
@@ -6,7 +6,7 @@
6
6
  | [Rate Monthly Reconciliation Report](features/monthly-reconciliation-report.md) | 1.0 | A monthly cron that emails an Excel reconciliation report covering all Rate subscription sales orders and their linked PayPal payments for the prior calendar mo | worker/crons/notifications/reports/rate/send_monthly_rate_purchases_report.php, worker/schedules/cron.worker.notification.json |
7
7
  | [Rate SalesOrder → NetSuite CashSale Export (postPost)](features/netsuite-cashsale-export.md) | 2.0 | Rate sells home-warranty / home-tech-support products. | _underscore/Model/Rate/SalesOrder.php, _underscore/Model/Rate/Item.php |
8
8
  | [Rate 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
- | [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 |
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 | > **⚠ DEPLOYING beta→production, NOT YET PROD-VERIFIED (as of 2026-07-23).** TRUE-79533 is > beta-verified (PM Paulina tested the WH purchase flow on beta) and | _underscore/Model/Rate/Entitlement.php, _underscore/Model/Client/Entitlement.php, _underscore/Model/Client/Address.php, dbchanges2/Client/2026-07-22a - EntitlementServiceAddressId.sql, dbchanges2/Core/2026-07-22a - EntitlementServiceAddressIdField.sql, dbchanges2/Client_Rate/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql, test/@Mark/Rate/verify_wholehome_per_address_guard.php |
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 |
12
12
  | [Rate](profile.md) | 2.0 | Rate is a mortgage/lending client. | |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: rate
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-07-23
9
+ updated: 2026-07-27
10
10
  owners: [mhammontree, tcox]
11
11
  files:
12
12
  - _underscore/Model/Rate/Entitlement.php
@@ -36,11 +36,14 @@ This is a base-`_underscore` interceptor specific to Rate's product (it lives un
36
36
  `Model/Rate/`); documented here as a Rate client-feature because the behavior and its
37
37
  monitoring are Rate-scoped.
38
38
 
39
- **As of 2026-07-07 (TRUE-79533)** the same `postPost` also persists the validated **service
40
- address** (`Entitlements.c_serviceAddressId`, `Addresses.isValidated=1`) — that persistence is
41
- wrapped so it never blocks this AIG-contract flow or the confirmation email. For Whole Home
42
- Warranty purchases, a new `prePost` guard now also **carrier-normalizes the address onto the
43
- payload before save**, so the AIG contract is created with the canonical address. See the
39
+ **As of TRUE-79533 (beta-verified 2026-07-27)** the same `postPost` also persists the validated
40
+ **service address** — now via the **standard** `Entitlements.serviceAddressId` FK (the Rate-custom
41
+ `c_serviceAddressId` was promoted to a base-model standard field) plus `Addresses.isValidated=1` —
42
+ wrapped so it never blocks this AIG-contract flow or the confirmation email. For Whole Home Warranty
43
+ purchases, a `prePost` guard also **carrier-normalizes the address onto the payload before save**, so
44
+ the AIG contract is created with the canonical address. Note both this AIG block and the WH pin read
45
+ the **same** nested `contact.primaryContactAddress.address` payload node, so a shallow request
46
+ `depth` starves both — see the WH guard doc's depth-starvation gotcha. See the
44
47
  [Whole Home Warranty per-address purchase guard](whole-home-warranty-purchase-guard.md).
45
48
 
46
49
  ## How it works
@@ -107,6 +110,11 @@ creation vs. cancellation. This is an accepted, documented limitation — not an
107
110
 
108
111
  ## Change history
109
112
 
113
+ - 2026-07-27 — TRUE-79533 beta-verified: updated the service-address note to the **standard**
114
+ `Entitlements.serviceAddressId` FK (the Rate-custom `c_serviceAddressId` was promoted to a
115
+ base-model standard field), and noted that this AIG block and the WH pin read the **same**
116
+ `contact.primaryContactAddress.address` payload node — so a shallow request `depth` starves both
117
+ (cross-linked the WH guard's depth-starvation gotcha). (mhammontree)
110
118
  - 2026-07-23 — Documented that the **AIG-success branch reassigns `$payload->uuid`** (as the contract
111
119
  number) inside the shared `postPost`, which silently broke the WH service-address pin on the
112
120
  pre-redesign build (pin keyed off the now-reassigned uuid). Fixed by snapshotting the entitlement
@@ -6,8 +6,8 @@ project: TOGa View Frontend
6
6
  client: rate
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-07-23
10
- owners: ["bala", "tcox"]
9
+ updated: 2026-07-27
10
+ owners: ["bala", "tcox", "mhammontree"]
11
11
  files:
12
12
  - src/components/ServiceCard/ServiceCard.tsx
13
13
  - src/components/ServiceCard/index.ts
@@ -20,6 +20,7 @@ files:
20
20
  - src/pages/Services/viewModels/useServicePageViewModel.ts
21
21
  - src/api/serviceAddressApi.ts
22
22
  - src/api/apiErrors.ts
23
+ - dbchanges2/Client_Rate/2026-07-24a - AddressIdFieldPermission.sql
23
24
  related:
24
25
  - clients/rate/profile.md
25
26
  - whole-home-warranty-purchase-guard.md
@@ -118,6 +119,14 @@ product types would need `detectServiceType` extended or overridden.
118
119
  `contact.primaryContactAddress` fallback made every unpinned card display the same (latest)
119
120
  address because that field is one shared, purchase-overwritten value per contact. An unpinned
120
121
  entitlement must render addressless.
122
+ - **The `id` field itself needs a read ACL when the FE fetches records by id (Client_Rate, fixed
123
+ 2026-07-24).** `fetchAddressesByIds` issues `GET /v2/addresses?fields=id,line1,...` — but without a
124
+ read grant on the **`Addresses.id` field** the whole call returns **403 `EZ-2`** and no address
125
+ renders (the numeric `id` field is ACL-gated like any other field, not implicitly readable). Fixed
126
+ by `dbchanges2/Client_Rate/2026-07-24a - AddressIdFieldPermission.sql`, granting READ
127
+ (`isWritable=0`) on `Addresses.id` to the same role(s) that already read the sibling display fields
128
+ (`line1` etc.). **Reusable lesson:** whenever the FE reads a record by its numeric `id` field, that
129
+ `id` field needs its own `AclFieldPermissions` read grant.
121
130
  - **The services fetch degrades gracefully on field-permission errors.** `apiErrors.ts`
122
131
  `isFieldPermissionError()` treats **`EV-8`** (field not registered — `identifiers.field`,
123
132
  singular) the **same as `EZ-2`** (no read ACL — `identifiers.fields`, array). When the
@@ -128,6 +137,12 @@ product types would need `detectServiceType` extended or overridden.
128
137
 
129
138
  ## Change history
130
139
 
140
+ - 2026-07-27 — TRUE-79533: recorded that `fetchAddressesByIds`
141
+ (`GET /v2/addresses?fields=id,line1,...`) returned **403 `EZ-2`** because the **`Addresses.id`
142
+ field** had no read ACL grant in `Client_Rate` — the numeric `id` is ACL-gated like any field. Fixed
143
+ by `dbchanges2/Client_Rate/2026-07-24a - AddressIdFieldPermission.sql` (READ, `isWritable=0`, to the
144
+ roles that already read the sibling display fields). Captured the reusable lesson: reading a record
145
+ by its `id` field needs a read grant on that `id` field itself. (mhammontree)
131
146
  - 2026-07-23 — TRUE-79533 (FE side): warranty card address now comes from the entitlement's **own**
132
147
  `serviceAddressId` pin (fetched by id via `fetchAddressesByIds`), renamed all FE
133
148
  `c_serviceAddressId` refs to the standard `serviceAddressId`, and **removed the
@@ -5,13 +5,14 @@ repo: _underscore
5
5
  project: _Underscore
6
6
  client: rate
7
7
  type: client-feature
8
- status: draft
9
- updated: 2026-07-23
8
+ status: active
9
+ updated: 2026-07-27
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
+ - toga2-view/src/pages/CheckOut/api/checkoutApi.ts
15
16
  - dbchanges2/Client/2026-07-22a - EntitlementServiceAddressId.sql
16
17
  - dbchanges2/Core/2026-07-22a - EntitlementServiceAddressIdField.sql
17
18
  - dbchanges2/Client_Rate/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql
@@ -22,23 +23,20 @@ related:
22
23
  - ../../../2.0/apps/_underscore/features/address-validation.md
23
24
  ---
24
25
 
25
- > **⚠ DEPLOYING beta→production, NOT YET PROD-VERIFIED (as of 2026-07-23).** TRUE-79533 is
26
- > beta-verified (PM Paulina tested the WH purchase flow on beta) and is now being promoted to
27
- > `_production`. **The service-address field was redesigned since the beta build:** it is no
28
- > longer a Rate-custom `c_serviceAddressId` field — it is now a **standard** `serviceAddressId`
29
- > FK on the **base** `_Model_Client_Entitlement` (see *How it works* and *Migration* below). Prod
30
- > launches clean (no pre-existing WH entitlements), so **no backfill ships**. Treat everything
31
- > below as forward-looking until the production purchase flow is verified. See the
32
- > **shared-interceptor merge hazard** gotcha and the **dangling-pin** / **unguarded prod
33
- > interceptor** gotchas before merging to `_production`.
34
- >
35
- > **🚨 LAUNCH BLOCKER — RESOLVED (migration built 2026-07-23, mhammontree; found 2026-07-23,
36
- > tcox) — the standard `serviceAddressId` field had NO READ ACL grant.** The custom→standard
37
- > redesign shipped the column + `Core.RecordFields` row but **not** the `AclFieldPermissions`
38
- > read grant, so `GET /v2/entitlements?fields=serviceAddressId` returned **403 `EZ-2`**. The fix
39
- > now ships as **`dbchanges2/Client_Rate/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql`**
40
- > (committed `b74f42a` on TRUE-79533) — see
41
- > **[Launch blocker: standard field needs a READ ACL grant](#launch-blocker-true-79533--the-standard-field-needs-a-read-acl-grant)**.
26
+ > **STATUS (2026-07-27): beta-verified end-to-end; promoting to `_production`.** The final blocker
27
+ > to a correct service-address pin turned out to be a **front-end request-`depth` bug**: Rate's
28
+ > checkout was sending `{depth:-1}` on `POST /entitlements`, so V2 built a **shallow** payload
29
+ > (`meta.calcDepth 1`) and `postPost` never received the nested
30
+ > `contact.primaryContactAddress.address` node it pins from — the pin silently no-op'd on every
31
+ > purchase (in a way that still passed in a deep-payload dev context). **Tanner** (toga2-view FE)
32
+ > removed the `depth:-1` override in `checkoutApi.ts createService`; verified on **beta 2026-07-27**
33
+ > — post-fix WH purchases pin `Entitlements.serviceAddressId` + set `Addresses.isValidated=1` with
34
+ > **no backfill**. See the **"low request `depth` starves `postPost`"** gotcha below (and the 2.0
35
+ > framework lesson it points to). `serviceAddressId` is a **standard** FK on the **base**
36
+ > `_Model_Client_Entitlement` (no longer the Rate-custom `c_serviceAddressId`), and its READ ACL
37
+ > grant shipped (`2026-07-23a`, committed `b74f42a`). Prod launches clean (no pre-existing WH
38
+ > entitlements), so **no backfill ships**. See the **shared-interceptor merge hazard** and the
39
+ > **dangling-pin** / **unguarded prod interceptor** gotchas before merging to `_production`.
42
40
 
43
41
  ## Summary
44
42
 
@@ -330,8 +328,39 @@ live carrier waterfall is opt-in via `RUN_LIVE=1` (defaults off). Verified **18/
330
328
  only carries type/identifier/childPolicy/precision, mirroring sibling FKs `saleItemId`/`vendorId`.
331
329
  - **No `_Query` bind API here** — use `_Database::escape()` / int-casts for all interpolated
332
330
  values.
331
+ - **Low request `depth` silently starved `postPost` of the address node (FE bug — root cause of the
332
+ final beta pin failures; fixed 2026-07-27).** A V2 `postPost` interceptor only receives the nested
333
+ records the **built response payload** includes. Rate's `toga2-view` checkout was POSTing
334
+ `{depth:-1}` on `POST /entitlements`, so V2 built a shallow payload (`meta.calcDepth 1`) and
335
+ `persistWarrantyServiceAddress()` — which reads `$payload->contact->primaryContactAddress->address`,
336
+ the **same node the AIG contract block reads** — got nothing, so the pin no-op'd on every purchase
337
+ (yet passed in deep-payload dev contexts, masking it). `ApiPayloadInterceptors.minDepth` is meant to
338
+ floor this (entitlements `postPost` row = 5), but the clamp did **not** actually deepen a `depth=-1`
339
+ request — the `WO-1` warning logged *"raised to minDepth 5"* while `calcDepth` stayed 1 (a **latent
340
+ framework bug worth its own ticket**). Fix that shipped: **Tanner** removed the `depth:-1` override
341
+ in `checkoutApi.ts createService` so the default deep payload reaches `postPost`. This starvation
342
+ broke BOTH the WH pin AND the pre-existing AIG contract-creation block (both read that node). See
343
+ the 2.0 framework lesson on payload-interceptor depth starvation.
344
+ - **Resolve the just-created entitlement from the hydrated `$payload`, NOT a DB read-back.** `postPost`
345
+ runs inside the still-open request transaction, so a fresh `_Query` SELECT (even forced onto the
346
+ write host — V2 already disables the read host for the whole create block) does **not** reliably
347
+ resolve the just-created nested rows in-request. Pin from the in-memory `$payload` node (hydrated by
348
+ `getFullModelData` — the same node the AIG block reads), not a read-back. Fingerprint of a lost pin:
349
+ `Addresses.isValidated=1` and `Entitlements.serviceAddressId` are **both-or-neither** (same persist
350
+ block) — check `isValidated` to see whether the pin fired. (General 2.0 framework lesson.)
333
351
 
334
352
  ## Change history
353
+ - 2026-07-27 — **Shipped working on beta end-to-end; status draft→active** (TRUE-79533). Found and
354
+ fixed the **final** pin failure: the FE was POSTing `{depth:-1}` on `POST /entitlements`, building a
355
+ shallow V2 payload (`calcDepth 1`) that **starved `postPost`** of the nested
356
+ `contact.primaryContactAddress.address` node (same node the AIG block reads), so the pin silently
357
+ no-op'd on every purchase — and `ApiPayloadInterceptors.minDepth` (=5) did **not** actually deepen a
358
+ `depth=-1` request (latent framework bug, own ticket). **Tanner** (toga2-view FE, kb-username not yet
359
+ known) removed the `depth:-1` override in `checkoutApi.ts createService`; verified on beta 2026-07-27
360
+ — WH purchases now pin `serviceAddressId` + set `Addresses.isValidated=1`, no backfill. Also recorded
361
+ the general lesson that `postPost` must resolve just-created records from the hydrated `$payload`, not
362
+ a same-request DB read-back (uncommitted nested writes aren't visible in-request). (mhammontree, with
363
+ Tanner)
335
364
 
336
365
  - 2026-07-23 — **READ-ACL grant BUILT — launch blocker resolved** (TRUE-79533, committed `b74f42a`).
337
366
  Added `dbchanges2/Client_Rate/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql`: a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.441",
3
+ "version": "1.0.442",
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",