toga-ai 1.0.421 → 1.0.423

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.
@@ -4,7 +4,7 @@
4
4
  |-----|---------|-------|
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
- | [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 |
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 |
8
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 |
9
9
  | [_ApiRequest ENCODE__JSON now sends Content-Type: application/json](features/apirequest-json-content-type.md) | `_ApiRequest::execute()`'s `ENCODE__JSON` branch json-encoded the request body but never set a `Content-Type` header. | _underscore/ApiRequest.php |
10
10
  | [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 |
@@ -21,7 +21,7 @@
21
21
  | [_Model magic-field access (__get without __isset)](features/model-magic-field-access.md) | `_Model` exposes DB columns as "magic" properties via `__get()`, but it defines **no** `__isset()`. | _underscore/Model/Core/Model.php |
22
22
  | [_Model::save() vs raw _Query — no atomic conditional update](features/model-save-vs-query-atomic-update.md) | `_Model::save()` is a plain load-then-write ORM primitive and **cannot express an atomic conditional update** (an optimistic-concurrency / row-claim guard such | _underscore/Model.php, _underscore/Query.php |
23
23
  | [NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL](features/netsuite-rest-client.md) | `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive every worker2/api2 NetSuite caller uses for record GETs, Suit | _underscore/Component/Api/Netsuite/Netsuite.php |
24
- | [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php |
24
+ | [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php, api2/Controller/Index.php |
25
25
  | [Persona Name Translation (PersonaTranslations sidecar)](features/persona-name-translation.md) | Serves Persona **names** in multiple languages by adding a per-language **sidecar** table `PersonaTranslations`, reusing the platform's existing metadata-driven | _underscore/Model/Client/PersonaTranslation.php, dbchanges2/Client/2026-07-22b - PersonaTranslations.sql, dbchanges2/Core/2026-07-22a - PersonaTranslationsRecord.sql, dbchanges2/Client/2026-07-22c - PersonaTranslationsAcl.sql, dbchanges2/Client_CompassCanada/2026-07-22 - PersonaTranslationsFrench.sql, toga2-commerce/src/pages/Account/view/MySettingsView.tsx |
26
26
  | [Recursive Item Fulfillments (upstream mirroring)](features/recursive-item-fulfillments.md) | In a multi-tier supply chain a sales order (SO) spawns a purchase order (PO) that becomes another SO downstream, and so on. | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillmentItem.php, _underscore/Model/Client/ItemFulfillmentItemUnit.php, _underscore/Model/Client/ItemFulfillmentPackage.php, _underscore/Model/Compass/AdvanceShippingNotice.php, dbchanges2/Core/2026-02-13 - 75601 - RecursiveItemFulfillmentCreation.sql, dbchanges2/Core/2026-06-04 - RecursiveItemFulfillmentPut.sql, dbchanges2/Client_Compass/2026-07-02a - FixSA133377TrackingSerialAndDuplicateIF.sql |
27
27
  | [Surface Resolver (_Model_Core_Surface::resolve — replaces Page::meta)](features/surface-resolver.md) | The runtime for the platform-wide **Surface** UI presentation layer: 9 `_underscore` models plus a cached resolver, `_Model_Core_Surface::resolve(&$api, string | _underscore/Model/Core/Surface.php, _underscore/Model/Client/AclRecordScript.php, _underscore/Model/Core/RecordScript.php, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Quad/2026-07-01a - GrantSurfacesMetaGroupScriptAcl.sql, dbchanges2/Client_CompassCanada/2026-07-01a - GrantSurfacesMetaGroupScriptAcl.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Client_Compass/2026-07-15f - SalesOrderRecordActionsRemoveDeadConfigRuleOverrides.sql, dbchanges2/Core/2026-07-17h - Update - ClearApprovalsFilterButtonConfig.sql, dbchanges2/Client_Compass/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Compass/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_Quad/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Quad/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, _underscore/Model/Core/SurfaceElement.php, _underscore/Model/Core/Action.php, _underscore/Model/Core/Vocabulary.php, _underscore/Model/Core/VocabularyTerm.php, _underscore/Model/Core/Message.php, _underscore/Model/Client/SurfaceOverride.php, _underscore/Model/Client/MessageTranslation.php, _underscore/Model/Client/ThemeToken.php, _underscore/Model/Core/Page.php, api2/Component/Api/V2/V2.php |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-07-16
9
+ updated: 2026-07-23
10
10
  owners: ["jcardinal", "rgirish", "mhammontree"]
11
11
  files:
12
12
  - _underscore/_underscore.php
@@ -344,21 +344,28 @@ multi-file UI components (`.php`/`.html`/`.css`/`.js`) invoked as `<_ComponentNa
344
344
  - **`_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
345
  - **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
346
 
347
- - **A controller exception is silently swallowed by `Route.php`, then masked as a view error.** In
348
- dispatch, `_underscore/Route.php` (~lines 462–468) wraps the controller call in
347
+ - **A controller exception used to be silently swallowed by `Route.php`, then masked as a view error
348
+ (FIXED 2026-07-23).** In dispatch, `_underscore/Route.php` previously wrapped the controller call in
349
349
  `catch (Exception $e) { _Database::transactionRollback(); }` — **no log, no rethrow, no debug
350
- bypass.** The controller response is left null, so Route.php falls through to its generic throw at
351
- line 525: `Failed to determine how to render view for route '<route>' ... './View/Index/<method>.html'`.
352
- **Therefore this "Failed to determine how to render view" fatal is very often a MASK for a real
353
- exception thrown inside the controller method** (commonly `_Controller_Index::api()`), not a genuine
354
- routing/view misconfiguration. A frequent culprit is a DB connect / "Unknown database" failure in
355
- `api()`'s **unguarded pre-execute block** (it registers `DB_LOGS` and queries `Logs.Api` for the
356
- transactionId-uniqueness check *before/outside* the only try/catch, which wraps just
357
- `$api->execute()` at ~line 205). When diagnosing the line-525 error, look for a thrown exception in
358
- the controller not the view layer. (Note: an *unmatched host* does NOT trigger this; `api()`
359
- returns a response object that gets JSON-encoded — only a thrown exception hits the line-525 path.)
360
- See `2.0/apps/api2/workflows/environment-configuration-and-provisioning.md`. Fix candidate: have
361
- Route.php log/rethrow the swallowed exception (or expose it under debug) instead of discarding it.
350
+ bypass.** The controller response was left null, so Route.php fell through to its generic throw:
351
+ `Failed to determine how to render view for route '<route>' ... './View/Index/<method>.html'`.
352
+ That "Failed to determine how to render view" fatal was therefore very often a MASK for a real
353
+ exception thrown inside the controller method (commonly `_Controller_Index::api()`), not a genuine
354
+ routing/view misconfiguration. **Fixed 2026-07-23:** the catch now logs the swallowed exception
355
+ (via `error_log`, message + trace) **and rethrows**, and it catches `\Throwable` (not just
356
+ `Exception`) so `Error`s (type errors, fatals) surface too — the rollback is still performed before
357
+ rethrow. The real exception now propagates instead of being reshaped into the misleading line-525
358
+ view error. When diagnosing, you now see the true controller exception directly. (Note: an
359
+ *unmatched host* does NOT trigger this; `api()` returns a response object that gets JSON-encoded —
360
+ only a thrown exception hits this path.) A frequent underlying culprit was a DB connect / "Unknown
361
+ database" failure in `api()`'s pre-execute block now separately guarded in api2 (see the api2
362
+ architecture doc's front-controller section). See
363
+ `2.0/apps/api2/workflows/environment-configuration-and-provisioning.md`.
364
+ - **Follow-up (deferred, separate ticket): production info-leak from unconditional `display_errors`.**
365
+ Now that Route.php rethrows, whatever global error handling renders the exception can leak the raw
366
+ message/trace to the client if `display_errors` is on unconditionally. Confirm `display_errors` is
367
+ disabled in production and that the framework's error surface returns a sanitized envelope (not a raw
368
+ stack trace) to API consumers. Not yet done — tracked as a follow-up.
362
369
 
363
370
  ## Change history
364
371
  - 2026-06-11 — Documented lazy transaction gotcha in `_Database::register()` (rgirish)
@@ -367,3 +374,4 @@ multi-file UI components (`.php`/`.html`/`.css`/`.js`) invoked as `<_ComponentNa
367
374
  - 2026-07-02 — Fixed framework-wide `FIELD_STORAGE` write-drop and folder-read bugs in `Model.php`; noted the remaining unconditional-refetch dirty-read follow-up. (mhammontree)
368
375
  - 2026-07-07 — Documented `_Query` writes-only async mode (`isAsync` → Worker `Infrastructure/Database/Query`) in the database-architecture section, linked `features/async-query-execution.md`, and noted the SQS-first `_Worker::runTask()` enqueue departure (caller does no MySQL; worker tier creates the row on pickup). (jcardinal)
369
376
  - 2026-07-16 — Documented the misleading "Unclosed '{'" parse-error gotcha (autoloader reports the outermost `class {` line / caller trace, not the true dropped-brace location; merge conflicts a common cause). First hit production 500 EO-1 via a dropped `_Email::send()` brace. (jcardinal)
377
+ - 2026-07-23 — Fixed the Route.php swallow→mask gotcha: the controller-call catch now log+rethrows and catches `\Throwable` (not just `Exception`), so the true controller exception surfaces instead of the misleading line-525 "Failed to determine how to render view" fatal. Noted the deferred production `display_errors` info-leak follow-up. (jcardinal)
@@ -7,13 +7,14 @@ client: shared
7
7
  type: feature
8
8
  status: active
9
9
  updated: 2026-07-21
10
- owners: ["jcardinal", "mhammontree"]
10
+ owners: ["jcardinal", "mhammontree", "tcox"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - _underscore/Model/Core/Page.php
14
14
  - _underscore/Model/Client/TrackingNumber.php
15
15
  - dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql
16
16
  - dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql
17
+ - dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql
17
18
  ---
18
19
 
19
20
  ## Summary
@@ -45,7 +46,35 @@ AclRecordPermissions → AclLogicGroups → AclLogicGroupExpressions →
45
46
 
46
47
  **Field visibility — `AclFieldPermissions`.** Record-level access alone does **not** expose
47
48
  fields. Each field needs a row: `recordFieldId`, `roleId`, `isWritable` (0 = read-only, 1 =
48
- writable). Without it the field is omitted from responses (and rejected on write).
49
+ writable). Without it the field is rejected on write **and** is unreadable:
50
+
51
+ - **Missing grant on a READ** (e.g. `GET /v2/<record>?fields=<field>`) → **`EZ-2`** (field-level
52
+ authorization denied). This is the read-side counterpart of the write code `EV-9`. A field can be
53
+ fully registered (`Core.RecordFields` row present, so no `EV-8`) yet still `EZ-2` because it has
54
+ no `AclFieldPermissions` row — **registering a field and granting access to it are two separate
55
+ steps**, and shipping the field registration without the grant is a recurring launch blocker.
56
+ - **`AclFieldPermissions` is `UNIQUE (recordFieldId, roleId)`** (Client blank DDL) — guard inserts
57
+ with `NOT EXISTS`. For a **read-only, server-written** field (one populated by a `postPost`
58
+ interceptor rather than the API caller), grant `isWritable = 0`.
59
+
60
+ ### Standard field vs. custom (`c_`) field — TWO different permission tables
61
+
62
+ The permission table depends on whether the field is a platform **standard** field or a client
63
+ **custom** field, and they are **not interchangeable** — granting the wrong one silently does
64
+ nothing:
65
+
66
+ | Field kind | Registered in | Authorized via | Keyed by |
67
+ |------------|---------------|----------------|----------|
68
+ | **Standard** (e.g. `serviceAddressId`) | `Core.RecordFields` | **`AclFieldPermissions`** | `Core.RecordFields` id |
69
+ | **Custom** (`c_` prefix) | `CustomRecordFields` | **`AclCustomFieldPermissions`** | `CustomRecordFields` id |
70
+
71
+ **Promoting a custom field to a standard field moves its grant to the other table.** When
72
+ `c_serviceAddressId` was promoted to the standard `serviceAddressId` (Rate WH guard, TRUE-79533),
73
+ its `CustomRecordFields` row was deleted, so the pending `AclCustomFieldPermissions` grant
74
+ (TRUE-80176) became a no-op — the replacement must be an `AclFieldPermissions` row. Resolve the
75
+ `recordFieldId` by **route + field-name subselect** against `Core.RecordFields`/`Core.Records`
76
+ (never hardcode), and clone a sibling field's per-role rows — e.g. `Client/2026-07-02c -
77
+ TrackingNumberNeedsReturnLabelFieldPermission.sql` is the precedent shape.
49
78
 
50
79
  ## Where ACL rows live: Core vs. Client database
51
80
 
@@ -170,6 +199,13 @@ the field.
170
199
 
171
200
  ## Change history
172
201
 
202
+ - **2026-07-23** — TRUE-79533: documented the **READ side** of `AclFieldPermissions` — a registered
203
+ field with no grant returns **`EZ-2`** on read (the counterpart of the write code `EV-9`), and
204
+ the **standard vs. custom** split: standard fields use `AclFieldPermissions` (keyed by
205
+ `Core.RecordFields` id), custom (`c_`) fields use `AclCustomFieldPermissions` (keyed by
206
+ `CustomRecordFields` id). Promoting `c_serviceAddressId` → standard `serviceAddressId` moved its
207
+ grant across tables and made the pending `AclCustomFieldPermissions` grant a no-op. Noted the
208
+ `UNIQUE (recordFieldId, roleId)` constraint and `isWritable=0` for server-written fields. (tcox)
173
209
  - **2026-07-21** — TRUE-79191: promoted the writable-field recipe from **3-file** to **FOUR
174
210
  requirements** — added the **generated model-class declaration** (`_underscore/Model/Client/<Name>.php`,
175
211
  e.g. `public $needsReturnLabel = self::FIELD_BOOLEAN;`) as the 4th, whose absence returns **`EO-1`**
@@ -6,12 +6,13 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-09
9
+ updated: 2026-07-23
10
10
  owners: ["dfranks", "jcardinal", "mhammontree", "apeterson"]
11
11
  files:
12
12
  - _underscore/Database.php
13
13
  - _underscore/ApiRequest.php
14
14
  - _underscore/Model/Client/Logs/Api.php
15
+ - api2/Controller/Index.php
15
16
  related:
16
17
  - ../architecture.md
17
18
  - ../workflows/local-db-refresh-from-beta.md
@@ -89,12 +90,29 @@ registration is region-aware in `api2/Controller/Index.php`; the alias const is
89
90
  set from beta — see [Refreshing a local dev DB from beta](../workflows/local-db-refresh-from-beta.md).
90
91
  This is a **local-environment** gap, not a code bug — do not "fix" it by disabling logging in
91
92
  shared code.
93
+ - **The shared Core Logs DB (`DB_LOGS`) has the same trap — and its name is resolved dynamically,
94
+ so "I have all the databases installed" can still fail.** api2's `Controller/Index.php::api()`
95
+ bootstrap registers `DB_LOGS` using a schema name read from a **`Core.Database` row**
96
+ (`id = CORE_LOGS_DATABASE_ID`), not a fixed literal. A Logs DB you created locally whose *name*
97
+ doesn't match that Core row still reads as **missing** → `mysqli_connect` `Unknown database`. Match
98
+ the local schema name to the `Core.Database` row (or fix the row), don't just "create a logs DB."
99
+ - **This Core Logs bootstrap runs OUTSIDE the try/catch that wraps `$api->execute()`** (the env
100
+ lookup, region/host `search()`, `_Model_Core_Database` instantiation, and `_Database::register()`
101
+ for `DB_CORE`/`DB_LOGS` all run before the guarded block at ~line 202 of `Controller/Index.php`).
102
+ A connect failure here threw past `api()` and got masked by `Route.php` as the misleading line-525
103
+ "Failed to determine how to render view" error — see the Route.php swallow→mask gotcha in
104
+ [_underscore architecture](../architecture.md#gotchas--known-issues).
92
105
  - Related 1.0 analogue: the legacy `App_` worker has the same hazard writing to `Logs.API`
93
106
  (`db_logs`) — the laptop trap there is documented separately in the worker NetSuite bootstrap
94
107
  notes.
95
108
 
96
109
  ## Change history
97
110
 
111
+ - 2026-07-23 — Documented the shared **Core Logs** (`DB_LOGS`) analogue of the logs trap: its schema
112
+ name is resolved from a `Core.Database` row (`id = CORE_LOGS_DATABASE_ID`) in
113
+ `api2/Controller/Index.php::api()`, so a name-mismatched local Logs DB still reads as missing; and
114
+ this bootstrap runs outside `api()`'s guarded block, so a connect failure was masked by Route.php's
115
+ line-525 view error. (jcardinal)
98
116
  - 2026-07-09 — Linked the new [Running a 2.0 app locally](../workflows/running-a-2.0-app-locally.md) runbook, which frames these three connections as one requirement of a full local browser run. (mhammontree)
99
117
  - 2026-07-09 — Linked the new [local-DB-refresh-from-beta workflow](../workflows/local-db-refresh-from-beta.md)
100
118
  from the logs-trap fix (refreshing all local schemas from beta covers the missing `Logs_<Id>`). (apeterson)
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-07-07
9
+ updated: 2026-07-23
10
10
  owners: [jcardinal, bala]
11
11
  files:
12
12
  - api2/Controller/Index.php
@@ -65,8 +65,21 @@ lands in `api()`.
65
65
  - **CORS:** permissive `Access-Control-Allow-*`; returns 200 for `OPTIONS` preflight.
66
66
  - **`/health`:** 200 + empty object (EB/LB probe — keep it working).
67
67
  - **Sentry:** initialized per request; EC2 instance metadata attached.
68
- - **Core DB:** registers `Core` (`DB_CORE`), preferring a region-local read host
69
- (`CORE_READHOST`). **Core Logs DB** (`DatabaseHost` id 11) registered region-aware as `DB_LOGS`.
68
+ - **Core DB bootstrap (now guarded, 2026-07-23):** registers `Core` (`DB_CORE`), preferring a
69
+ region-local read host (`CORE_READHOST`), then registers the **Core Logs DB** region-aware as
70
+ `DB_LOGS`. This whole block runs in a **pre-execute phase that executes *before* the `execute()`
71
+ try/catch**, so a failure here historically escaped that guard and became a fatal. Two traps:
72
+ (1) the Core Logs **schema name is resolved from a `Core.Database` row** (`id =
73
+ CORE_LOGS_DATABASE_ID`), so a **name-mismatched local Logs DB** (one whose actual schema name
74
+ differs from the Core-recorded name) reads as **missing** and `mysqli_connect` throws `Unknown
75
+ database` — a common local-dev breakage, not a code bug. (2) That connect failure used to be a
76
+ fatal. **Fixed 2026-07-23:** the pre-execute Core/Logs bootstrap is wrapped in a
77
+ `try/catch (\Throwable)` that, on failure, returns the standard **`INVALID_CONFIGURATION`**
78
+ error envelope and reports to **Sentry** instead of dying with an unguarded fatal. Note the
79
+ rollback in that catch is a **guarded no-op** — with no transaction started yet,
80
+ `_Database::transactionRollback()` short-circuits safely (`Database.php:219–226`). See
81
+ [_underscore architecture — Route.php log+rethrow gotcha](../_underscore/architecture.md) for the
82
+ upstream masking behavior this pairs with, and the per-client-database-connections feature doc.
70
83
  - **Host dispatch:** lowercase `HTTP_HOST`, strip trailing domain, switch on the remainder.
71
84
  - **Transaction/logging invariant (JSON path):** wraps the call in transactions; on
72
85
  **success** commit logs then data; on **failure** commit logs but **roll back data**; on
@@ -153,6 +166,12 @@ Core/Client/Logs DB passwords, **AWS access key id + secret**, and third-party A
153
166
  carries a **GitHub PAT**. These should be rotated and moved to SSM Parameter Store / EB env
154
167
  properties. **Flag this if you touch config or deploy.**
155
168
 
169
+ **Follow-up (deferred, separate ticket): raw exception disclosure to clients.** The error paths that
170
+ surface a caught `\Throwable` (now that Route.php rethrows and the bootstrap guard reports failures)
171
+ must not return raw `getMessage()`/`getTrace()` output in the client-facing envelope — that leaks
172
+ internal paths, schema names, and stack frames to API consumers. Sanitize the client envelope
173
+ (generic message + code; full detail to Sentry/logs only). Not yet done.
174
+
156
175
  ## When making changes here
157
176
 
158
177
  - **Adding/altering an endpoint is usually a data change, not code** — define `Records` +
@@ -167,3 +186,6 @@ properties. **Flag this if you touch config or deploy.**
167
186
  the duplicate check.
168
187
  - Preserve the commit-logs / rollback-data-on-failure invariant when editing the controller
169
188
  or `execute()`.
189
+
190
+ ## Change history
191
+ - 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)
@@ -7,7 +7,7 @@ client: shared
7
7
  type: feature
8
8
  status: active
9
9
  updated: 2026-07-21
10
- owners: [mhammontree]
10
+ owners: [mhammontree, tcox]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - _underscore/Model/Client/TrackingNumber.php
@@ -36,6 +36,7 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
36
36
  | **EV-9** | No permission to write field | The field exists but the caller's role has no **`AclFieldPermissions`** grant (`isWritable=1`) in the CLIENT DB | Add the `AclFieldPermissions` row for the role (clone a writable sibling field's grant) |
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
  | **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 |
39
+ | **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 |
39
40
  | **EV-5** | Duplicate `transactionId` | The globally-unique `transactionId` was reused | Send a fresh unique `transactionId` per request |
40
41
  | **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 |
41
42
 
@@ -44,9 +45,12 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
44
45
  1. **Resolve the role.** Field/script ACLs are keyed by `roleId` **in the CLIENT DB**, and role
45
46
  ids differ per client — resolve by subselect (`SELECT id FROM Roles WHERE name='Base'`), never
46
47
  hardcode. To find the right grant shape, inspect a **working sibling** field/script's rows.
47
- 2. **EV-8 vs EV-9** are the two halves of adding a writable field: EV-8 = the Core `RecordFields`
48
- registration is missing; EV-9 = the field exists but the Client `AclFieldPermissions` grant is
49
- missing. You will typically hit EV-8 first, fix it, then hit EV-9.
48
+ 2. **EV-8 vs EV-9 vs EZ-2** three states of one field. **EV-8** = the Core `RecordFields`
49
+ registration is missing (field unknown). Once registered, a **write** with no grant is **EV-9**
50
+ and a **read** with no grant is **EZ-2** both mean the `AclFieldPermissions` row is missing,
51
+ differing only by direction. Registering a field and granting access to it are separate steps:
52
+ a field can pass EV-8 yet still EZ-2/EV-9. (For custom `c_` fields the grant table is
53
+ `AclCustomFieldPermissions`, not `AclFieldPermissions`.)
50
54
  3. **EV-6 vs EZ-1** are the two halves of exposing a scripted API: EV-6 = no `Core.RecordScripts`
51
55
  route (the segment falls through to record lookup); EZ-1 = the route exists but there's no
52
56
  `AclRecordScripts` dispatch grant for the caller's role.
@@ -67,6 +71,10 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
67
71
 
68
72
  ## Change history
69
73
 
74
+ - 2026-07-23 — TRUE-79533: added **`EZ-2`** (field-level READ authorization denied) — the read-side
75
+ counterpart of `EV-9`; a registered field (no `EV-8`) with no `AclFieldPermissions` read grant.
76
+ Clarified EV-8/EV-9/EZ-2 as three states of one field and the standard-vs-custom grant-table
77
+ split. Found via the Rate WH `serviceAddressId` standard-field promotion. (tcox)
70
78
  - 2026-07-17 — TRUE-79191: documented the EV-6/EV-8/EV-9/EZ-1 troubleshooting map (plus EV-5/EV-12)
71
79
  discovered wiring the Signature Type field and the `addresses/validate` scripted route on Fulfill
72
80
  & Ship — each code mapped to the missing migration (RecordField / AclFieldPermissions /
@@ -4,4 +4,6 @@
4
4
  |-----|---------|-------|
5
5
  | [TOGa View Frontend (toga2-view) Architecture](architecture.md) | `toga2-view` is the **React/TypeScript single-page frontend** for the TOGa 2.0 platform — the customer-facing web app (home warranty / tech-support portals). | toga2-view/src/main.tsx, toga2-view/src/App.tsx, toga2-view/src/routes.tsx, toga2-view/src/api/axiosInstance.ts, toga2-view/src/api/apiFunctions.ts, toga2-view/src/utils/queryHelpers.ts, toga2-view/src/contexts/AuthContext.tsx, toga2-view/src/contexts/useUserStore.ts, toga2-view/src/hooks/useAuthenticationFlow.ts, toga2-view/vite.config.ts, toga2-view/package.json |
6
6
  | [Mobile Nav Header](features/mobile-nav-header.md) | The `MobileNavToggle` component renders the fixed 72 px header (hamburger + Rate logo) and the slide-in drawer nav. | toga2-view/src/components/MobileNav/MobileNav.tsx, toga2-view/public/assets/Rate_Logo.svg |
7
+ | [/v2 Query-String Builder (assembleOptions / where coercion)](features/query-string-builder.md) | `src/utils/queryHelpers.ts` converts a structured JS options object (`fields`, `where`, `join`/`ojoin`, `sort`, …) into the `api2` `/v2` query string. | toga2-view/src/utils/queryHelpers.ts, toga2-view/src/utils/queryHelpers.test.ts |
7
8
  | [Service Card Component](features/service-card.md) | The `ServiceCard` component renders a single service subscription (tech support or home warranty) on both the Home and Services pages. | toga2-view/src/components/ServiceCard/ServiceCard.tsx, toga2-view/src/pages/Services/view/ServicesPage.tsx, toga2-view/src/pages/Home/view/HomePage.tsx, toga2-view/src/constants/bundleConstants.ts, toga2-view/src/pages/Services/viewModels/DUMMYFIELDS/SERVICESDUMMYFIELDS.json |
9
+ | [ZipValidation — contact phone is a prefill default, never a lock](features/zip-validation-contact-phone.md) | The `ZipValidation` page collects the covered-property address and a **contact phone number** as part of the purchase / address-validation entry flow (the front | toga2-view/src/pages/ZipValidation/view/ZipValidation.tsx |
@@ -0,0 +1,64 @@
1
+ ---
2
+ title: "/v2 Query-String Builder (assembleOptions / where coercion)"
3
+ framework: "2.0"
4
+ repo: toga2-view
5
+ project: TOGa View Frontend
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-23
10
+ owners: ["tcox"]
11
+ files:
12
+ - toga2-view/src/utils/queryHelpers.ts
13
+ - toga2-view/src/utils/queryHelpers.test.ts
14
+ related:
15
+ - ../architecture.md
16
+ - ../../../../clients/rate/features/service-card-entitlements.md
17
+ ---
18
+
19
+ ## Summary
20
+
21
+ `src/utils/queryHelpers.ts` converts a structured JS options object (`fields`, `where`,
22
+ `join`/`ojoin`, `sort`, …) into the `api2` `/v2` query string. Every data call in the app is built
23
+ here (see the [architecture doc](../architecture.md)), so a bug in the builder fails the request
24
+ **client-side, before any HTTP goes out** — no request appears in the network tab, which makes it
25
+ easy to misread as an API problem.
26
+
27
+ ## Key files / entry points
28
+
29
+ - `queryHelpers.ts` — `assembleOptions()` (top-level builder) and `assembleOptionsWhere()`
30
+ (serializes the `where` array), plus `urlEncode()` (per-value encoding).
31
+ - `queryHelpers.test.ts` — unit/regression tests for the builder.
32
+
33
+ ## How it works
34
+
35
+ 1. `assembleOptions()` walks the options object and delegates each `where` condition to
36
+ `assembleOptionsWhere()`.
37
+ 2. Each condition's value is passed through `urlEncode()` before being appended to the query
38
+ string.
39
+ 3. **Value coercion.** `urlEncode()` calls `String.prototype.replace`, so it requires a **string**.
40
+ `assembleOptionsWhere()` **coerces numeric condition values to `String` before `urlEncode`** —
41
+ without the coercion, a numeric `where` value (e.g. `where Addresses.id = 14`) throws
42
+ `input.replace is not a function` and aborts the whole request.
43
+
44
+ ## Gotchas / known issues
45
+
46
+ - **Numeric `where` values used to crash the builder.** Every `where` in the app historically
47
+ happened to pass **string** values, so the first numeric caller — `fetchAddressesByIds` resolving
48
+ per-entitlement service addresses (`where Addresses.id IN (…)` / `= 14`) — hit
49
+ `input.replace is not a function` in `urlEncode`, and **no `/v2/addresses` request was ever
50
+ sent** (the throw is client-side). Fixed by coercing numeric condition values to `String` in
51
+ `assembleOptionsWhere`. Regression tests in `queryHelpers.test.ts` (5 cases incl. the exact
52
+ addresses-by-id shape; 4 failed pre-fix with the production error).
53
+ - **A builder throw looks like an API outage but isn't.** If a fetch "does nothing" and there is no
54
+ request in the network panel, suspect the option object shape (a non-string `where` value, a
55
+ malformed `fields` entry) before suspecting `api2`.
56
+
57
+ ## Change history
58
+
59
+ - 2026-07-23 — Documented the builder and fixed `assembleOptionsWhere` to coerce **numeric** `where`
60
+ values to `String` before `urlEncode` — a numeric value (first hit via `fetchAddressesByIds`)
61
+ threw `input.replace is not a function` and silently aborted the request client-side. Added
62
+ regression tests. (tcox)
63
+ </content>
64
+ </invoke>
@@ -0,0 +1,49 @@
1
+ ---
2
+ title: "ZipValidation — contact phone is a prefill default, never a lock"
3
+ framework: "2.0"
4
+ repo: toga2-view
5
+ project: TOGa View Frontend
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-23
10
+ owners: ["tcox"]
11
+ files:
12
+ - toga2-view/src/pages/ZipValidation/view/ZipValidation.tsx
13
+ related:
14
+ - ../architecture.md
15
+ - ../../../../clients/rate/features/whole-home-warranty-purchase-guard.md
16
+ ---
17
+
18
+ ## Summary
19
+
20
+ The `ZipValidation` page collects the covered-property address and a **contact phone number** as
21
+ part of the purchase / address-validation entry flow (the front end for the WH purchase guard — see
22
+ [whole-home-warranty-purchase-guard](../../../../clients/rate/features/whole-home-warranty-purchase-guard.md)).
23
+ `handleContinue` saves the entered phone into the user store after each purchase.
24
+
25
+ ## Decision — a stored phone is a PREFILL DEFAULT, not a lock
26
+
27
+ The phone field is **always editable**. A phone previously saved to the user store is used only to
28
+ **prefill** the field; it must never disable it. A single customer can cover **different
29
+ properties**, each of which may have a **different contact number**, so locking the field to the
30
+ first value entered is wrong.
31
+
32
+ The **save-if-different** behavior is retained: `handleContinue` still writes the entered phone to
33
+ the store when it changed.
34
+
35
+ ## Gotchas / known issues
36
+
37
+ - **Do not re-disable the phone field on "store already has a phone".** The original code disabled
38
+ the field whenever the user store held a phone number — and because `handleContinue` saves the
39
+ entered phone after each purchase, the **first purchase permanently locked the field for every
40
+ later visit** (a self-reinforcing lock). The fix removed `isPhoneDisabled` state, the `disabled`
41
+ prop, and the greyed-out styling. Keep it editable.
42
+
43
+ ## Change history
44
+
45
+ - 2026-07-23 — Removed the phone-field lock: a stored phone now prefills but never disables the
46
+ field (a different covered property can have a different contact number). Previously the field
47
+ was disabled once the store held a phone, and the post-purchase save made the first purchase lock
48
+ it for every later visit. `save-if-different` retained. (tcox)
49
+ </content>
@@ -23,7 +23,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
23
23
  - **dbchanges2** (Database Changes) _(framework core)_ — 3 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
24
24
  - **toga2-supply** (TOGa Supply) — 3 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
25
25
  - **saml** (SAML SSO Gateway) — 3 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
26
- - **toga2-view** (TOGa View Frontend) — 4 doc(s) → [2.0/apps/toga2-view/INDEX.md](2.0/apps/toga2-view/INDEX.md)
26
+ - **toga2-view** (TOGa View Frontend) — 6 doc(s) → [2.0/apps/toga2-view/INDEX.md](2.0/apps/toga2-view/INDEX.md)
27
27
  - **toga2-hub** (TOGa Hub) — 2 doc(s) → [2.0/apps/toga2-hub/INDEX.md](2.0/apps/toga2-hub/INDEX.md)
28
28
  - **talos** (TOGa IQ) — 7 doc(s) → [2.0/apps/talos/INDEX.md](2.0/apps/talos/INDEX.md)
29
29
  - **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
@@ -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/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 |
9
+ | [Service Card Entitlement Display](features/service-card-entitlements.md) | 2.0 | Rate's home and services pages display one service card per purchased entitlement. | src/components/ServiceCard/ServiceCard.tsx, src/components/ServiceCard/index.ts, src/hooks/useBundleServices.ts, src/hooks/useActiveServices.ts, src/pages/Home/api/homeApi.ts, src/pages/Home/view/HomePage.tsx, src/pages/Home/viewModels/useHomePageViewModel.ts, src/pages/Services/view/ServicesPage.tsx, src/pages/Services/viewModels/useServicePageViewModel.ts, src/api/serviceAddressApi.ts, src/api/apiErrors.ts |
10
10
  | [Rate Service-Purchase Confirmation Emails (Tech / Warranty)](features/service-purchase-emails.md) | 2.0 | When a Rate customer purchases a service, a confirmation email is sent. | _underscore/Model/Rate/Entitlement.php, worker2/Worker/Notification/EmailTemplate.php, dbchanges2/Client_Rate/2026-06-30a - Rate purchase email templates.sql |
11
- | [Rate Whole Home Warranty Per-Address Purchase Guard](features/whole-home-warranty-purchase-guard.md) | 2.0 | > **⚠ DEPLOYING beta→production, NOT YET PROD-VERIFIED (as of 2026-07-23).** TRUE-79533 is > beta-verified (PM Paulina tested the WH purchase flow on beta) and | _underscore/Model/Rate/Entitlement.php, _underscore/Model/Client/Entitlement.php, _underscore/Model/Client/Address.php, dbchanges2/Client/2026-07-22a - EntitlementServiceAddressId.sql, dbchanges2/Core/2026-07-22a - EntitlementServiceAddressIdField.sql, test/@Mark/Rate/verify_wholehome_per_address_guard.php |
11
+ | [Rate Whole Home Warranty Per-Address Purchase Guard](features/whole-home-warranty-purchase-guard.md) | 2.0 | > **⚠ DEPLOYING beta→production, NOT YET PROD-VERIFIED (as of 2026-07-23).** TRUE-79533 is > beta-verified (PM Paulina tested the WH purchase flow on beta) and | _underscore/Model/Rate/Entitlement.php, _underscore/Model/Client/Entitlement.php, _underscore/Model/Client/Address.php, dbchanges2/Client/2026-07-22a - EntitlementServiceAddressId.sql, dbchanges2/Core/2026-07-22a - EntitlementServiceAddressIdField.sql, dbchanges2/Client/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. | |
@@ -2,23 +2,27 @@
2
2
  title: "Service Card Entitlement Display"
3
3
  framework: "2.0"
4
4
  repo: toga2-view
5
- project: TOGa View
5
+ project: TOGa View Frontend
6
6
  client: rate
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-06-23
10
- owners: ["bala"]
9
+ updated: 2026-07-23
10
+ owners: ["bala", "tcox"]
11
11
  files:
12
12
  - src/components/ServiceCard/ServiceCard.tsx
13
13
  - src/components/ServiceCard/index.ts
14
14
  - src/hooks/useBundleServices.ts
15
+ - src/hooks/useActiveServices.ts
15
16
  - src/pages/Home/api/homeApi.ts
16
17
  - src/pages/Home/view/HomePage.tsx
17
18
  - src/pages/Home/viewModels/useHomePageViewModel.ts
18
19
  - src/pages/Services/view/ServicesPage.tsx
19
20
  - src/pages/Services/viewModels/useServicePageViewModel.ts
21
+ - src/api/serviceAddressApi.ts
22
+ - src/api/apiErrors.ts
20
23
  related:
21
24
  - clients/rate/profile.md
25
+ - whole-home-warranty-purchase-guard.md
22
26
  ---
23
27
 
24
28
  ## Summary
@@ -57,22 +61,36 @@ regardless of how many entitlements the user had purchased.
57
61
 
58
62
  4. `ServiceCard` renders the data rows based on `serviceType`:
59
63
  - Tech / other → row 2: **Price** (`$XX.XX`)
60
- - Warranty → row 2: **Address** (`contact.primaryContactAddress.address.line1`)
64
+ - Warranty → row 2: **Address** — the entitlement's **own pinned service address**, resolved
65
+ from `Entitlements.serviceAddressId` (see below). If the entitlement is unpinned
66
+ (`serviceAddressId` NULL), the card renders **without an address** — this is intentional (see
67
+ the "own pin only" gotcha).
61
68
 
62
69
  5. Home page slices `allServices` to 3 (`HOME_SERVICE_LIMIT`). If more exist, a tertiary
63
70
  "View All Services" text link appears below the cards pointing to `/services`.
64
71
 
65
72
  6. Services page renders all entitlements with no cap in a `grid-cols-1 md:grid-cols-2` grid.
66
73
 
67
- ## Data model
74
+ ## Service address: `serviceAddressId` (per-entitlement pin), not the contact's shared address
68
75
 
69
- Address path in DB:
70
- ```
71
- Entitlements.contactId
72
- Contacts.primaryContactAddressId
73
- ContactAddresses.id ContactAddresses.addressId
74
- → Addresses.line1
75
- ```
76
+ As of 2026-07-23 (TRUE-79533) the warranty card's address comes from the entitlement's **own**
77
+ `serviceAddressId` FK — the address pinned by the WH purchase guard's `postPost` (see
78
+ [whole-home-warranty-purchase-guard](whole-home-warranty-purchase-guard.md)). The front end fetches
79
+ those addresses by id via `fetchAddressesByIds` (`src/api/serviceAddressApi.ts`,
80
+ `GET /v2/addresses?where=Addresses.id IN (…)`) and joins them onto each entitlement.
81
+
82
+ - **Field rename.** Every FE reference to the old Rate-custom `c_serviceAddressId` was renamed to
83
+ the standard `serviceAddressId` (`useActiveServices.ts`, `useBundleServices.ts`, `homeApi.ts`,
84
+ comments in `serviceAddressApi.ts`) to match the backend custom→standard field promotion.
85
+ - **The old `contact.primaryContactAddress` display fallback was REMOVED** (`useBundleServices.ts`,
86
+ `homeApi.ts`). The contact's primary address is **one shared value per contact**, overwritten by
87
+ each purchase — so falling back to it made **every unpinned card show the same/latest address**.
88
+ A card now shows only the entitlement's own pin, rendering addressless when unpinned (truthful —
89
+ it surfaces missing pins instead of masking them).
90
+
91
+ Legacy address path (contact-derived — no longer used for the card's address, retained only as
92
+ context for older data): `Entitlements.contactId → Contacts.primaryContactAddressId →
93
+ ContactAddresses.addressId → Addresses`.
76
94
 
77
95
  Contract number comes from `Entitlements.number` (auto-incremented with ET prefix, e.g. `ET100017`).
78
96
 
@@ -96,8 +114,26 @@ product types would need `detectServiceType` extended or overridden.
96
114
  `AddSubscriptionSheet`) replaces the old "Get Started" inactive bundle cards.
97
115
  - **`useBundleServices` is shared** — both `useHomePageViewModel` and `useServicePageViewModel`
98
116
  call it. Changes to the hook affect both pages.
117
+ - **Show the entitlement's OWN pin only — never the contact's shared address.** The removed
118
+ `contact.primaryContactAddress` fallback made every unpinned card display the same (latest)
119
+ address because that field is one shared, purchase-overwritten value per contact. An unpinned
120
+ entitlement must render addressless.
121
+ - **The services fetch degrades gracefully on field-permission errors.** `apiErrors.ts`
122
+ `isFieldPermissionError()` treats **`EV-8`** (field not registered — `identifiers.field`,
123
+ singular) the **same as `EZ-2`** (no read ACL — `identifiers.fields`, array). When the
124
+ `serviceAddressId` field is missing or ungranted on the API, the services fetch **degrades to
125
+ addressless rendering instead of failing** — which makes the FE deploy order **independent** of
126
+ the backend field-registration / ACL-grant migrations (see the WH guard doc's launch-blocker and
127
+ symptom-signature notes). Do not narrow this back to a single code.
99
128
 
100
129
  ## Change history
101
130
 
131
+ - 2026-07-23 — TRUE-79533 (FE side): warranty card address now comes from the entitlement's **own**
132
+ `serviceAddressId` pin (fetched by id via `fetchAddressesByIds`), renamed all FE
133
+ `c_serviceAddressId` refs to the standard `serviceAddressId`, and **removed the
134
+ `contact.primaryContactAddress` display fallback** (it was one shared value per contact, so every
135
+ unpinned card showed the latest address). Unpinned entitlements now render addressless. Hardened
136
+ `apiErrors.ts isFieldPermissionError()` to treat `EV-8` like `EZ-2` so the services fetch degrades
137
+ to addressless rendering (FE deploy order independent of the DB field/ACL migrations). (tcox)
102
138
  - 2026-06-23 — Initial implementation: per-entitlement card display, new ServiceCard component,
103
139
  address via LEFT JOIN, 3-card home limit with View All Services tertiary button (bala)
@@ -7,13 +7,14 @@ client: rate
7
7
  type: client-feature
8
8
  status: draft
9
9
  updated: 2026-07-23
10
- owners: [mhammontree]
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
15
  - dbchanges2/Client/2026-07-22a - EntitlementServiceAddressId.sql
16
16
  - dbchanges2/Core/2026-07-22a - EntitlementServiceAddressIdField.sql
17
+ - dbchanges2/Client/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql
17
18
  - test/@Mark/Rate/verify_wholehome_per_address_guard.php
18
19
  related:
19
20
  - clients/rate/profile.md
@@ -30,6 +31,13 @@ related:
30
31
  > below as forward-looking until the production purchase flow is verified. See the
31
32
  > **shared-interceptor merge hazard** gotcha and the **dangling-pin** / **unguarded prod
32
33
  > interceptor** gotchas before merging to `_production`.
34
+ >
35
+ > **🚨 LAUNCH BLOCKER (added 2026-07-23, tcox) — the standard `serviceAddressId` field has NO
36
+ > READ ACL grant.** The custom→standard redesign shipped the column + `Core.RecordFields` row
37
+ > but **not** the `AclFieldPermissions` read grant, so `GET /v2/entitlements?fields=serviceAddressId`
38
+ > returns **403 `EZ-2`** for roles 1/2/4. Prod does **not** launch clean without the new
39
+ > `Client/2026-07-23a` field-permission migration below — see
40
+ > **[Launch blocker: standard field needs a READ ACL grant](#launch-blocker-true-79533--the-standard-field-needs-a-read-acl-grant)**.
33
41
 
34
42
  ## Summary
35
43
 
@@ -132,6 +140,52 @@ during backend review (Jeff). This is the single biggest change since the beta b
132
140
  registration was redundant (see the **unguarded prod interceptor** gotcha) and its backfill was
133
141
  dropped (prod launches clean).
134
142
 
143
+ ## Launch blocker (TRUE-79533) — the standard field needs a READ ACL grant
144
+
145
+ **The custom→standard redesign shipped the column and the `Core.RecordFields` row but NOT the
146
+ `AclFieldPermissions` READ grant.** Probe-verified on **beta 2026-07-23**:
147
+ `GET /v2/entitlements?fields=serviceAddressId` returns **403 `EZ-2`** for roles 1, 2, and 4
148
+ (Entitlements has `Core.Records.aclDatabase = 'CLIENT'`, so the grant lives in each client DB).
149
+ Without the grant, **production launches with the exact `EZ-2` failure beta hit** — the field is
150
+ registered but unreadable, so the service cards cannot fetch the pin.
151
+
152
+ **Standard vs. custom field permissions (the reason the earlier fix is now moot).** A **standard**
153
+ field is authorized through **`AclFieldPermissions`**, keyed by the **`Core.RecordFields` id**. A
154
+ **custom** (`c_`) field was authorized through **`AclCustomFieldPermissions`**, keyed by the
155
+ `CustomRecordFields` id. The old custom-field grant (**TRUE-80176**) targeted
156
+ `AclCustomFieldPermissions` and is now **moot — close it unmerged**: its guarded INSERT no-ops
157
+ because the `CustomRecordFields` row for `c_serviceAddressId` was deleted in the standard-field
158
+ redesign. The read grant now has to be an **`AclFieldPermissions`** row instead.
159
+
160
+ **Needed migration (proposed, on the TRUE-79533 branch):**
161
+ `dbchanges2/Client/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql`, following the
162
+ existing `Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql` precedent:
163
+ - Resolve field ids via `Core.RecordFields` / `Core.Records` subselects (route `'entitlements'`,
164
+ fields `'serviceAddressId'` **and** its sibling `'number'`) — never hardcode ids.
165
+ - **Mirror the sibling `number` field's per-role grants**, but force **`isWritable = 0`**:
166
+ `serviceAddressId` is written **server-side** by the guard's `postPost`, never by the API caller,
167
+ so it must be read-only.
168
+ - Guard each INSERT with `NOT EXISTS` (verified against the Client blank DDL:
169
+ `AclFieldPermissions` is `UNIQUE (recordFieldId, roleId)`).
170
+
171
+ ## Beta data migration — old-column pins are orphaned by the redesign (beta only, NO prod impact)
172
+
173
+ Entitlements purchased on **beta before the redesign** carry their pin in the old
174
+ `c_serviceAddressId` column; the new `serviceAddressId` column is **NULL** for them, so their cards
175
+ render **addressless**. One-time beta fix (prod launches clean, so this ships **nowhere near prod**):
176
+
177
+ ```sql
178
+ UPDATE Client_Rate.Entitlements
179
+ SET serviceAddressId = c_serviceAddressId
180
+ WHERE serviceAddressId IS NULL AND c_serviceAddressId IS NOT NULL;
181
+ ```
182
+
183
+ **Symptom signatures (which layer is misconfigured):**
184
+ - A **front-end `EV-8` on `c_serviceAddressId`** means the redesign SQL **ran** — the old custom
185
+ field is gone/unregistered (`EV-8` = field not registered).
186
+ - A **`EZ-2` on `serviceAddressId`** means the redesign ran but the **read ACL grant is missing**
187
+ (see the launch blocker above).
188
+
135
189
  ## Rate data model (prod-verified 2026-07-07)
136
190
 
137
191
  - **WH product** = `Items.id 3` / `partNumber 1429124` "Whole Home Warranty - Monthly". Tech
@@ -243,6 +297,17 @@ live carrier waterfall is opt-in via `RUN_LIVE=1` (defaults off). Verified **18/
243
297
 
244
298
  ## Change history
245
299
 
300
+ - 2026-07-23 — **LAUNCH BLOCKER found: the standard `serviceAddressId` field has NO read ACL grant**
301
+ (tcox). Probe-verified on beta: `GET /v2/entitlements?fields=serviceAddressId` → 403 `EZ-2` for
302
+ roles 1/2/4 (`aclDatabase=CLIENT`). Standard fields are authorized via **`AclFieldPermissions`**
303
+ (keyed by `Core.RecordFields` id), **not** `AclCustomFieldPermissions` — so the old custom-field
304
+ grant **TRUE-80176 is moot** (close unmerged; its INSERT no-ops now that the `CustomRecordFields`
305
+ row is deleted). Fix: new `dbchanges2/Client/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql`
306
+ on the TRUE-79533 branch, mirroring sibling field `number`'s per-role grants with `isWritable=0`
307
+ (field is written server-side by `postPost`), resolving ids by route/field subselect and guarded
308
+ by `NOT EXISTS`. Also recorded the beta-only orphaned-pin backfill (old `c_serviceAddressId` pins
309
+ are NULL in the new column; `UPDATE … SET serviceAddressId = c_serviceAddressId`) and the
310
+ `EV-8`-on-`c_serviceAddressId` vs `EZ-2`-on-`serviceAddressId` symptom signatures. (tcox)
246
311
  - 2026-07-23 — **Service-address field promoted CUSTOM → STANDARD** (TRUE-79533, reviewer Jeff)
247
312
  and CodeRabbit hardenings, deploying beta→prod. `c_serviceAddressId` on `_Model_Rate_Entitlement`
248
313
  is gone; `serviceAddressId` is now an FK on the **base** `_Model_Client_Entitlement`, registered
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.421",
3
+ "version": "1.0.423",
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",