toga-ai 1.0.283 → 1.0.285

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,9 @@
4
4
  |-----|---------|-------|
5
5
  | [_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 |
6
6
  | [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, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql |
7
+ | [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 |
7
8
  | [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 |
9
+ | [Asynchronous Query Execution (writes-only, via Worker)](features/async-query-execution.md) | `_Query` can run a **write** query asynchronously so a long/slow write does not hold a request-scoped DB connection open long enough to hit **"MySQL server has | _underscore/Query.php, worker2/Worker/Infrastructure/Database.php, worker2/Worker/Team/Transcripts.php |
8
10
  | [Carrier Shipping Labels (UPS/FedEx) & NetSuite Item Fulfillment](features/carrier-shipping-labels.md) | Backend mechanics behind TOGa Supply's Fulfill & Ship: buying a carrier label (UPS/FedEx), persisting it, and creating the NetSuite Item Fulfillment with tracki | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ItemFulfillments/TrackingNumber.php, _underscore/Component/Library/LabelPdf/LabelPdf.php, _underscore/Component/Library/Carriers/ShipmentRequest/ShipmentRequest.php, _underscore/Component/Library/Carriers/Ups/Ups.php, _underscore/Component/Library/Carriers/Fedex/Fedex.php, _underscore/Trait/Netsuite/ItemFulfillment.php, _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Component/Library/NetSuite/NetSuite.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ShippingMethod.php, _underscore/Model.php, _underscore/Cloud.php |
9
11
  | [Client Email Template Sending](features/email-template-sending.md) | `_Model_Client_EmailTemplate` sends a stored, client-defined email template by UUID. | _underscore/Model/Client/EmailTemplate.php, _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php, _underscore/Email.php |
10
12
  | [Error Reporting — Issue/Event Aggregation (exceptionHandler)](features/error-reporting-issue-event.md) | `_underscore`'s global exception handler persists every uncaught exception into a two-table **Issue / Event** model in the **shared Core Logs DB** (`_underscore | _underscore/Error.php, _underscore/Model/Core/Logs/Issue.php, _underscore/Model/Core/Logs/Event.php, dbchanges2/Logs/2026-07-06 - Issue and Event tables.sql |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-07-02
9
+ updated: 2026-07-07
10
10
  owners: ["jcardinal", "rgirish", "mhammontree"]
11
11
  files:
12
12
  - _underscore/_underscore.php
@@ -119,6 +119,16 @@ multi-tenant client-DB lookup from Core. `Query.php` wraps MySQLi with read/writ
119
119
  auto-routing and result helpers: `fetchRows()`, `fetchRow()`, `fetchOne()`,
120
120
  `fetchRowsAssocArray()`, `fetchKeyValuePairs()`, `fetchValues()`.
121
121
 
122
+ `_Query` also supports a **writes-only async mode** (`isAsync` — 3rd constructor arg /
123
+ `setIsAsync()`): a slow WRITE is dispatched to the Worker tier via `_Worker::runTask()`
124
+ instead of running on the request connection, so a long write cannot hold a request-scoped
125
+ connection open long enough to hit **"MySQL server has gone away."** See
126
+ [features/async-query-execution.md](./features/async-query-execution.md). This rides on the
127
+ **SQS-first** `_Worker::runTask()` enqueue path (below): `runTask()` no longer INSERTs the
128
+ `WorkerJobs` row on the caller's connection — it sends `{uuid, action, parameters}` to the
129
+ worker SQS queue and the worker tier creates/tracks the row on its own fresh connection at
130
+ pickup (see the worker2 architecture doc's SQS-first decision).
131
+
122
132
  ### Physical infrastructure
123
133
 
124
134
  Each **environment** is one or more MySQL clusters. Every cluster has a single
@@ -338,3 +348,4 @@ multi-file UI components (`.php`/`.html`/`.css`/`.js`) invoked as `<_ComponentNa
338
348
  - 2026-06-25 — Added the Surface platform UI presentation/configuration layer (DB-driven UI config replacing `Page::meta()`, CTO-reviewed AGREE-WITH-ADJUSTMENTS) (jcardinal)
339
349
  - 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)
340
350
  - 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)
351
+ - 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)
@@ -0,0 +1,76 @@
1
+ ---
2
+ title: "Address Validation (carrier waterfall + validateAddress scripted endpoint)"
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-07
10
+ owners: [mhammontree]
11
+ files:
12
+ - _underscore/Model/Client/Address.php
13
+ related:
14
+ - ../../api2/features/scripted-api-post-body-args.md
15
+ - ../../api2/architecture.md
16
+ - ../../../clients/rate/features/whole-home-warranty-purchase-guard.md
17
+ ---
18
+
19
+ ## Summary
20
+
21
+ `_Model_Client_Address::validateAddress` verifies a US address against a **carrier
22
+ waterfall (USPS → FedEx → UPS)** and returns a single canonical, carrier-normalized
23
+ address. It is exposed to the frontend as the **global** scripted endpoint
24
+ `GET /addresses/validateAddress`, and is also callable server-side by any model/interceptor
25
+ that needs to validate + normalize an address before persisting it (e.g. the Rate
26
+ Whole Home Warranty purchase guard).
27
+
28
+ ## Key files / entry points
29
+
30
+ - `_Model_Client_Address::validateAddress(&$api, $address1, $address2, $city, $state, $zip)`
31
+ (`_underscore/Model/Client/Address.php`) — runs USPS, then FedEx, then UPS.
32
+ - **On success** (carriers agree) returns keys: `success:true`, `address1`, `address2`,
33
+ `city`, `state` (2-letter code), `zipCode`, `country`. UPS additionally supplies
34
+ `zip4`/`zip5`.
35
+ - **On failure** (all carriers fail) returns the **last (UPS) response** with
36
+ `success:false` (and an `error`).
37
+
38
+ ## The scripted endpoint is registered in the DB, not in source (parity gap)
39
+
40
+ The `GET /addresses/validateAddress` endpoint the frontend calls is wired as a **global**
41
+ scripted endpoint in **`Core.RecordScripts`** (id 17: `recordId` 13 = Addresses, method
42
+ `GET`, route `validateAddress`, `phpMethod` `validateAddress`). Verified in production
43
+ 2026-07-07.
44
+
45
+ - It is **not** in any client-level `CustomRecordScripts` (Rate's is empty), and it is
46
+ **not** present in `dbchanges2` source — the registration exists **only in the live DB**.
47
+ Treat this as a known source/DB parity gap: you will not find the endpoint by grepping
48
+ `dbchanges2`.
49
+ - **Dispatch precedence:** the V2 engine checks `Core.RecordScripts` **before** a client's
50
+ `CustomRecordScripts`, so a global script wins for every client unless a client overrides it.
51
+ - **Response path:** a scripted method's return value is placed at
52
+ `data.<record>.<route>` in the API envelope — so `validateAddress` (record = Addresses,
53
+ route = validateAddress) returns to **`data.addresses.validateAddress`**, which is exactly
54
+ what the frontend reads.
55
+
56
+ ## Client variations
57
+
58
+ None — this is shared engine + framework behavior. The carrier credentials are resolved
59
+ per the standard client carrier config.
60
+
61
+ ## Gotchas / known issues
62
+
63
+ - **DB-only registration.** Because the `Core.RecordScripts` row is not in `dbchanges2`,
64
+ the endpoint's existence is invisible to source search and is not reproduced by replaying
65
+ migrations into a fresh DB. Register scripted endpoints in `dbchanges2` going forward.
66
+ - **Waterfall requires agreement on success.** The success shape is only returned when the
67
+ carriers agree; a `success:false` result carries the last (UPS) response, not a merged one.
68
+
69
+ ## Change history
70
+
71
+ - 2026-07-07 — Documented `_Model_Client_Address::validateAddress` (USPS→FedEx→UPS waterfall
72
+ + normalized success shape) and the global `GET /addresses/validateAddress`
73
+ (`Core.RecordScripts` id 17) endpoint, including the dispatch precedence, the
74
+ `data.<record>.<route>` response path, and the dbchanges2 source/DB parity gap
75
+ (registration lives only in the live DB). Prod-verified while building the Rate WH purchase
76
+ guard (TRUE-79533). (mhammontree)
@@ -0,0 +1,89 @@
1
+ ---
2
+ title: Asynchronous Query Execution (writes-only, via Worker)
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-07
10
+ owners: ["jcardinal"]
11
+ files:
12
+ - _underscore/Query.php
13
+ - worker2/Worker/Infrastructure/Database.php
14
+ - worker2/Worker/Team/Transcripts.php
15
+ related:
16
+ - ../architecture.md
17
+ - ./per-client-database-connections.md
18
+ ---
19
+
20
+ ## Summary
21
+
22
+ `_Query` can run a **write** query asynchronously so a long/slow write does not hold a
23
+ request-scoped DB connection open long enough to hit **"MySQL server has gone away"**.
24
+ Instead of executing on the local connection, the query is dispatched as a Worker task
25
+ (`Infrastructure/Database/Query`) that opens its own connection on the target DB, runs the
26
+ write, and commits. It is **writes-only by design** — an async SELECT captures no result set
27
+ and is a harmless no-op.
28
+
29
+ ## Key files / entry points
30
+
31
+ - **`_underscore/Query.php`** — `_Query` gained an `isAsync` flag: constructor 3rd param
32
+ `bool $isAsync = false`, a `setIsAsync()` setter, and an `_isAsync` property. When async,
33
+ `execute()` resolves the target DB connection config and dispatches a Worker task instead of
34
+ running locally.
35
+ - **`worker2/Worker/Infrastructure/Database.php`** — `_Worker_Infrastructure_Database::Query()`
36
+ is the worker-side action that runs the write on the target DB and commits.
37
+ - **`worker2/Worker/Team/Transcripts.php`** — live example caller in `SyncKnowledgeBases()`
38
+ (lines ~911 / ~925).
39
+
40
+ ## How it works
41
+
42
+ 1. **Opt in.** Construct with the third arg true:
43
+ `new _Query($sql, _underscore::DB_TEAM, true);` (or call `setIsAsync(true)` before the
44
+ query is set).
45
+ 2. **Dispatch (in `execute()`).** When `_isAsync` is true, `execute()` does **not** run the
46
+ query locally. It resolves the target DB connection config — from `_Database::$_registers`
47
+ or the `_Config` `DATABASE` group (direct hostname, or `writehost`/`readhost` + `backuphost`
48
+ params) — and calls
49
+ `_Worker::runTask(action: 'Infrastructure/Database/Query', parameters: {hostname, database,
50
+ username, password, query})`.
51
+ 3. **Worker side.** `_Worker_Infrastructure_Database::Query()` calls `_Database::register(...)`
52
+ under the alias `'Query'`, runs `new _Query($query, 'Query')` to execute the write on the
53
+ worker's own connection, then commits with `_Database::transactionCommit('Query')`.
54
+ 4. **Writes only.** Async SELECT captures no result set (fire-and-forget) and is treated as a
55
+ harmless no-op — do not use async for reads.
56
+
57
+ ## Correctness gotchas (all fixed this session — do not regress)
58
+
59
+ - **Constructor order (critical).** `setIsAsync()` must run **before** `setQuery()`, because
60
+ `setQuery()` executes non-SELECT queries immediately. With the original order (setQuery first)
61
+ every async WRITE ran synchronously and the async path was never taken. The constructor was
62
+ reordered to set the async flag first.
63
+ - **Worker-side commit (critical).** `_Database::register()` with an alias auto-starts a lazy
64
+ transaction; the write flips autocommit off and is rolled back when the worker connection
65
+ closes unless explicitly committed. The worker action must call
66
+ `_Database::transactionCommit('Query')` after the write. This is the known _underscore
67
+ [lazy-transaction gotcha](../architecture.md) — the same trap that silently discarded Rate
68
+ SAML user INSERTs.
69
+ - **Double-dispatch.** The async branch leaves `_results` null, and `execute()` is guarded by
70
+ `is_null(_results)`, so `__destruct()`/getters re-fire the job. Fixed by setting a sentinel
71
+ (`_results = true`) at the end of the async branch.
72
+ - **Dangling local transaction.** The original async check ran **after** the
73
+ write-connection/transaction setup, so a dispatching async write opened a local writer
74
+ connection and started a local transaction it never committed. Fixed by moving the async
75
+ dispatch to the **top** of `execute()`, before any read/write connection or transaction setup.
76
+
77
+ ## Known open items
78
+
79
+ - **SECURITY (open, developer aware).** DB username/password are currently sent in **plaintext**
80
+ through the Worker queue payload (SQS + `WorkerJobs` table). Being handled separately: the
81
+ worker should resolve credentials from config by database name rather than receiving them in
82
+ the payload. Do not build on the plaintext-credential path.
83
+
84
+ ## Change history
85
+
86
+ - 2026-07-07 — Built writes-only async query execution (`_Query` `isAsync` → Worker
87
+ `Infrastructure/Database/Query` action); fixed four correctness bugs found in review
88
+ (constructor order, worker-side commit, double-dispatch sentinel, dangling local
89
+ transaction). Credential-in-payload security item left open. (jcardinal)
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-06-08
9
+ updated: 2026-07-07
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - worker2/Controller/Index.php
@@ -15,6 +15,7 @@ files:
15
15
  - _underscore/Worker.php
16
16
  related:
17
17
  - ./features/creating-worker-actions.md
18
+ - ../_underscore/features/async-query-execution.md
18
19
  ---
19
20
 
20
21
  ## Summary
@@ -92,8 +93,29 @@ New path (`{workerJobId}` present):
92
93
  5. Call `initialize()` if present, then the action with spread parameters.
93
94
  6. **Check-out:** `UPDATE dtCompleted, executionTime, isSuccess, failureReason`. Return 200.
94
95
 
95
- Legacy path (`action` present, no `workerJobId`): used by `_Worker::runTask()` debug mode
96
- only; runs directly with no `WorkerJobs` tracking.
96
+ Direct-payload path (`action` present, no `workerJobId`): formerly the debug-only "legacy"
97
+ path (untracked, could return HTTP 500). As of 2026-07-07 it is a **first-class tracked
98
+ path** — it is how the SQS-first `_Worker::runTask()` (below) delivers production jobs. The
99
+ message carries `{uuid, action, parameters}`; the worker creates and tracks the `WorkerJobs`
100
+ row on its own fresh connection and **always returns HTTP 200** (no DLQ + 3600s visibility
101
+ timeout means any 500 = poison-message storm):
102
+
103
+ 1. **Dedupe guard.** SELECT `WorkerJobs` by `uuid`. Skip (200) **only if `isSuccess IS NOT
104
+ NULL`** (already completed). A row that exists but is incomplete (prior crash / in-flight)
105
+ is **re-run**, not dropped — mirrors the `workerJobId` guard. (SQS is at-least-once.)
106
+ 2. **New job → INSERT** `(uuid, jobType='ACTION', action, parameters, dtQueued=NOW(),
107
+ dtStarted=NOW(), instanceId)`. A UNIQUE index on `WorkerJobs.uuid` (added in `dbchanges2`)
108
+ backstops the SELECT→INSERT TOCTOU race.
109
+ 3. **INSERT-failure disambiguation** is by **re-resolving the uuid**, not by parsing the
110
+ error code: if the row now exists it was a benign concurrent duplicate (skip 200);
111
+ otherwise the write genuinely failed — log CRITICAL + `\Sentry\captureException`, still
112
+ return 200.
113
+ 4. `dtQueued=NOW()` at insert (not NULL) so JobScheduler's `dtQueued IS NULL` recovery does
114
+ not re-queue these rows; the watchdog still covers them via `dtStarted`.
115
+
116
+ > **Known follow-up (not done):** the ~100-line log-DB-registration + `initialize()` blocks
117
+ > are duplicated between the `workerJobId` path and the direct-payload path — candidate for
118
+ > extraction to shared private helpers.
97
119
 
98
120
  ## Action → class/method routing
99
121
 
@@ -103,9 +125,17 @@ file `Worker/Team/Github.php`.
103
125
 
104
126
  ## Programmatic invocation & manual retry
105
127
 
106
- - **`_Worker::runTask(action, parameters)`** (`_underscore/Worker.php`) — INSERT
107
- `WorkerJobs` **`transactionCommit(DB_CORE)` before SQS** send `{workerJobId}`.
108
- Debug mode posts synchronously with no tracking.
128
+ - **`_Worker::runTask(action, parameters)`** (`_underscore/Worker.php`) — **SQS-first as of
129
+ 2026-07-07:** the production branch does **no MySQL** (no INSERT, no commit). It generates a
130
+ client-side `uuid` and sends `{uuid, action, parameters}` to the worker SQS queue; the worker
131
+ tier creates+tracks the `WorkerJobs` row on pickup (see direct-payload path above). Payload is
132
+ capped at `_Worker::SQS_MAX_MESSAGE_BYTES = 262144` — `runTask()` **throws** if the JSON
133
+ exceeds the SQS 256KB limit (loud failure, not a silent drop); oversized params must be
134
+ offloaded to S3 by the caller. Debug branch unchanged (synchronous POST). **Why:** long-running
135
+ scripts hit "MySQL server has gone away" on the caller's stale Core connection during the old
136
+ INSERT; SQS sends do not depend on the DB connection, so row creation moved off the caller
137
+ entirely. This is the enqueue path used by `_Query` writes-only async mode. See the SQS-first
138
+ decision + trade-off under *Key design decisions*.
109
139
  - **`_Worker_Infrastructure_Worker::Retry($workerJobId)`** — resets the job fields,
110
140
  commits before SQS, sends `{workerJobId}` immediately. Sets `dtQueued=NOW()` (not NULL)
111
141
  so JobScheduler doesn't double-pick it.
@@ -118,17 +148,56 @@ file `Worker/Team/Github.php`.
118
148
  ## Critical transaction pattern
119
149
 
120
150
  **Any INSERT/UPDATE that must be visible to another connection before an SQS message is
121
- delivered must be immediately committed.** Three sites: `Retry()`, `_Worker::runTask()`,
122
- and the `Controller/Index.php` check-in. Reason: the caller's MySQL transaction is still
151
+ delivered must be immediately committed.** Sites: `Retry()` and the `Controller/Index.php`
152
+ check-in. (`_Worker::runTask()` no longer applies as of 2026-07-07 it does no MySQL; the
153
+ worker tier creates the row itself on pickup.) Reason: the caller's MySQL transaction is still
123
154
  open when SQS delivers; the worker reads in a separate connection and, if uncommitted, its
124
155
  SELECT returns nothing and the guard fires ("already processed or not found — skipping").
125
156
 
126
157
  ## Key design decisions
127
158
 
128
- MySQL-first (SQS on success) prevents lost jobs · always HTTP 200 (WorkerJobs tracks
129
- failures, SQS retry unwanted) · 3600s visibility timeout for hour-long jobs · no DLQ
130
- (developer-controlled `Retry()` preferred) · all fallback payloads to S3 (no size split) ·
131
- SQS failure → reset `dtQueued=NULL` (rescheduled next minute) · `maxExecutionTime` on
132
- CronJobs, env var for ACTION jobs · `/Webhook` appended enforces method-name convention ·
133
- API Gateway in front of Lambda (SCP blocks Function URLs) · `workerJobId` lookup keeps SQS
134
- messages tiny · commit before SQS avoids the delivery-before-commit race.
159
+ MySQL-first for the **Lambda/webhook/cron ingestion paths** (SQS on success) prevents lost
160
+ jobs · always HTTP 200 (WorkerJobs tracks failures, SQS retry unwanted) · 3600s visibility
161
+ timeout for hour-long jobs · no DLQ (developer-controlled `Retry()` preferred) · all fallback
162
+ payloads to S3 (no size split) · SQS failure → reset `dtQueued=NULL` (rescheduled next
163
+ minute) · `maxExecutionTime` on CronJobs, env var for ACTION jobs · `/Webhook` appended
164
+ enforces method-name convention · API Gateway in front of Lambda (SCP blocks Function URLs) ·
165
+ `workerJobId` lookup keeps SQS messages tiny · commit before SQS avoids the
166
+ delivery-before-commit race.
167
+
168
+ ### `_Worker::runTask()` is SQS-first, NOT MySQL-first (2026-07-07 — intentional departure)
169
+
170
+ For **programmatic** enqueue via `_Worker::runTask()`, the MySQL-first rule was deliberately
171
+ reversed. Long-running PHP scripts were hitting "MySQL server has gone away" on the caller's
172
+ `WorkerJobs` INSERT because the caller's Core connection had gone **stale** (not because the
173
+ server was dying). Approach chosen (**A — SQS-first**): the caller does no MySQL; it sends
174
+ `{uuid, action, parameters}` to SQS and the worker tier creates+tracks the row on its own
175
+ fresh connection at pickup.
176
+
177
+ **Rejected alternative (CTO-recommended):** Approach C/D — reconnect-on-stale inside
178
+ `_Database`, keeping the durable id-based (MySQL-first) pipeline. The CTO review advised
179
+ **against** Approach A because the failure is a stale PHP connection rather than a dying
180
+ server, and because promoting the debug-only untracked path risked poison-message storms and
181
+ duplicate execution. The developer chose Approach A anyway, **accepting the durability
182
+ trade-off**: a bare SQS-send failure loses the job, and jobs are not visible in `WorkerJobs`
183
+ until worker pickup. The CTO review's known-danger mitigations were then implemented — uuid
184
+ dedupe that re-runs incomplete rows, UNIQUE `uuid` index for the TOCTOU race, uuid
185
+ re-resolution to disambiguate INSERT failures (CRITICAL log + Sentry on genuine failure),
186
+ always-200, and a 256KB payload cap that throws. Record kept so a future reader understands
187
+ the MySQL-first design was departed from **on purpose** here.
188
+
189
+ **Gotchas from this change:**
190
+ - `dtQueued` on these rows now reflects worker pickup/row-creation time, **not** true SQS
191
+ enqueue time — semantic drift for any queue-latency metric.
192
+ - Debug/legacy messages without a `uuid` get a random one, so dedupe does not apply to them —
193
+ an HTTP-layer retry of a debug call could double-run.
194
+
195
+ ## Change history
196
+
197
+ - 2026-06-08 — Initial worker2 architecture doc. (jcardinal)
198
+ - 2026-07-07 — `_Worker::runTask()` moved to SQS-first (caller does no MySQL; worker tier
199
+ creates/tracks the row on pickup); the former debug-only direct-payload path in
200
+ `Controller/Index.php` promoted to a first-class tracked path with uuid dedupe, UNIQUE-index
201
+ TOCTOU backstop, uuid re-resolution on INSERT failure, and a 256KB payload cap. Documented as
202
+ an intentional departure from MySQL-first (CTO recommended against; trade-off accepted).
203
+ Related: `_underscore` `_Query` writes-only async mode. (jcardinal)
@@ -17,7 +17,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
17
17
 
18
18
  ## 2.0 framework
19
19
 
20
- - **_underscore** (_Underscore) _(framework core)_ — 22 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
20
+ - **_underscore** (_Underscore) _(framework core)_ — 25 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
21
  - **worker2** (Worker) — 27 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
22
22
  - **api2** (API) — 10 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
23
23
  - **dbchanges2** (Database Changes) _(framework core)_ — 3 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -8,4 +8,5 @@
8
8
  | [Rate SAML SSO](features/saml-sso.md) | 2.0 | Rate uses Azure AD as its IdP (`login.rate.com`). | _underscore/Model/Rate/ClientAuthentication.php, saml/Controller/Index.php, toga2-view/src/hooks/useAuthenticationFlow.ts |
9
9
  | [Service Card Entitlement Display](features/service-card-entitlements.md) | 2.0 | Rate's home and services pages display one service card per purchased entitlement. | src/components/ServiceCard/ServiceCard.tsx, src/components/ServiceCard/index.ts, src/hooks/useBundleServices.ts, src/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 |
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 | A customer may hold **one active Whole Home Warranty (WH) per validated address, globally** (across all borrowers). | _underscore/Model/Rate/Entitlement.php, _underscore/Model/Client/Address.php, dbchanges2/Client_Rate/2026-07-07a - WholeHomeWarrantyPerAddressGuard.sql |
11
12
  | [Rate](profile.md) | 2.0 | Rate is a mortgage/lending client. | |
@@ -14,6 +14,7 @@ files:
14
14
  - worker2/Worker/Monitors/RateEntitlement.php
15
15
  related:
16
16
  - clients/rate/profile.md
17
+ - clients/rate/features/whole-home-warranty-purchase-guard.md
17
18
  - ../../../2.0/apps/worker2/features/monitoring-framework.md
18
19
  ---
19
20
 
@@ -35,6 +36,13 @@ This is a base-`_underscore` interceptor specific to Rate's product (it lives un
35
36
  `Model/Rate/`); documented here as a Rate client-feature because the behavior and its
36
37
  monitoring are Rate-scoped.
37
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
44
+ [Whole Home Warranty per-address purchase guard](whole-home-warranty-purchase-guard.md).
45
+
38
46
  ## How it works
39
47
 
40
48
  1. `postPost` runs after an entitlement is saved. It proceeds **only if** the payload has a
@@ -91,6 +99,10 @@ creation vs. cancellation. This is an accepted, documented limitation — not an
91
99
 
92
100
  ## Change history
93
101
 
102
+ - 2026-07-07 — `postPost` now also persists the validated service address
103
+ (`Entitlements.c_serviceAddressId`, `Addresses.isValidated=1`), wrapped so it never blocks the
104
+ AIG-contract/email flow; a new WH `prePost` guard carrier-normalizes the address onto the
105
+ payload before save (TRUE-79533). See the WH per-address purchase guard doc. (mhammontree)
94
106
  - 2026-06-29 — Documented the silent-failure AIG warranty-contract interceptor and the
95
107
  external log-scan monitor (`_Worker_Monitors_RateEntitlement`, TRUE-79129), including the
96
108
  accepted detection limitation that auth-induced failures are not attributable (shared
@@ -0,0 +1,140 @@
1
+ ---
2
+ title: "Rate Whole Home Warranty Per-Address Purchase Guard"
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: rate
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-07-07
10
+ owners: [mhammontree]
11
+ files:
12
+ - _underscore/Model/Rate/Entitlement.php
13
+ - _underscore/Model/Client/Address.php
14
+ - dbchanges2/Client_Rate/2026-07-07a - WholeHomeWarrantyPerAddressGuard.sql
15
+ related:
16
+ - clients/rate/profile.md
17
+ - clients/rate/features/aig-contract-creation.md
18
+ - ../../../2.0/apps/_underscore/features/address-validation.md
19
+ ---
20
+
21
+ ## Summary
22
+
23
+ A customer may hold **one active Whole Home Warranty (WH) per validated address, globally**
24
+ (across all borrowers). This guard enforces that business rule at purchase time on the Rate
25
+ Entitlements route via a `prePost` interceptor on `_Model_Rate_Entitlement`
26
+ (`_underscore/Model/Rate/Entitlement.php`, Core.Records recordId **191**, registered PRE/POST).
27
+
28
+ For **WH purchases only** (WH is identified by the sale-item title containing `"warranty"`,
29
+ consistent with `resolvePurchaseProduct`), the guard:
30
+
31
+ 1. **Requires a service address** from `$payload->contact->primaryContactAddress->address`.
32
+ 2. **Hard-blocks** the purchase if
33
+ [`_Model_Client_Address::validateAddress`](../../../2.0/apps/_underscore/features/address-validation.md)
34
+ (USPS→FedEx→UPS waterfall) returns `success:false` — no entitlement is created; the error
35
+ surfaces to the frontend toaster.
36
+ 3. **Adopts the carrier-normalized address** (`address1`/`address2`/`city`/`state`/`zipCode`)
37
+ and writes it back onto the payload, so both the saved address and the AIG contract use the
38
+ canonical form.
39
+ 4. **Hard-blocks a second active WH at the same physical address globally** via
40
+ `hasActiveWarrantyAtAddress()`.
41
+
42
+ `postPost` then persists the validated service address after save (sets `Addresses.isValidated=1`
43
+ and `Entitlements.c_serviceAddressId`), wrapped so it never blocks the existing AIG-contract /
44
+ confirmation-email flow.
45
+
46
+ Ticket: TRUE-79533. Business rule owner: PM Paulina.
47
+
48
+ ## Business rules (decided this session — Mark / Paulina)
49
+
50
+ - **Invalid address → HARD BLOCK.** An address that fails carrier validation blocks the
51
+ purchase entirely (no entitlement), surfaced as an API error to the frontend toaster.
52
+ - **Uniqueness is GLOBAL per address**, not per-borrower — a second active WH on the same
53
+ physical address is rejected regardless of which borrower buys it.
54
+ - **Address line2 distinguishes units.** A landlord's apartment units are distinct addresses:
55
+ 2A / 2B / 2C = three separate WHs. Uniqueness keys on the full normalized address including
56
+ line2.
57
+ - **Persist the validated address ON the entitlement** (new `c_serviceAddressId` FK) rather
58
+ than deriving it from the fragile contact-primary-address path, and **backfill** existing WH
59
+ entitlements so the global rule covers production data already present.
60
+
61
+ ## How it works
62
+
63
+ 1. **`prePost` (PRE/POST interceptor).** Fires only for WH sale items (title LIKE
64
+ `%warranty%`).
65
+ 2. Reads the service address off `$payload->contact->primaryContactAddress->address`; missing
66
+ address → block.
67
+ 3. Calls `_Model_Client_Address::validateAddress`; `success:false` → block. On success, copies
68
+ the normalized `address1/address2/city/state/zipCode` back onto the payload.
69
+ 4. `hasActiveWarrantyAtAddress()` joins `Entitlements` → `Items` (title LIKE `%warranty%`, with
70
+ WH `Items.id = 3` as an OR fallback) → **active** `Subscriptions` (`isActive = 1 AND
71
+ dateCancelled IS NULL`) → `Addresses` on the new `c_serviceAddressId` column. A match → block
72
+ the second active WH.
73
+ - **The dedup read disables the query cache** (`_Database::useQueryCache(false)`, restored in
74
+ a `finally`) so it sees rows committed by a just-prior purchase rather than a stale cached
75
+ result set.
76
+ 5. **`postPost`.** After save, persists the validated service address: sets
77
+ `Addresses.isValidated = 1` and writes `Entitlements.c_serviceAddressId`. This block is
78
+ wrapped so a failure never interrupts the AIG-contract creation or the confirmation email
79
+ (see the [AIG contract creation doc](aig-contract-creation.md) — the same `postPost`).
80
+ 6. All SQL uses `_Database::escape()` / int-casts — `_Query` has **no bind API** in this path.
81
+
82
+ ## Migration (`dbchanges2/Client_Rate/2026-07-07a - WholeHomeWarrantyPerAddressGuard.sql`)
83
+
84
+ - Adds `Entitlements.c_serviceAddressId INT NULL` + an index.
85
+ - Guarded (`NOT EXISTS`) registration of `CustomRecordFields` for `c_serviceAddressId`
86
+ (recordId 191, type **NUMBER**).
87
+ - Guarded registration of the `ApiPayloadInterceptors` PRE/POST rows.
88
+ - A one-time, idempotent backfill of `c_serviceAddressId` for existing WH entitlements, sourced
89
+ from `Contacts` → `ContactAddresses` → `Addresses`.
90
+
91
+ ## Rate data model (prod-verified 2026-07-07)
92
+
93
+ - **WH product** = `Items.id 3` / `partNumber 1429124` "Whole Home Warranty - Monthly". Tech
94
+ products are `Items.id` 1 and 2.
95
+ - **Active-subscription signal is on `Subscriptions`** (`isActive` tinyint default 1,
96
+ `dateCancelled`, `dateEnd`, `dtRequestToCancel`). `Entitlements` has **no `isActive`**.
97
+ - **Entitlements had no address linkage before this ticket.** `SalesOrders` has
98
+ `shipToAddressId`/`billToAddressId` but they are NULL for Rate. Before `c_serviceAddressId`,
99
+ the address was reachable only via `Entitlement.contactId` → `Contacts.primaryContactAddressId`
100
+ → `ContactAddresses.addressId` → `Addresses`.
101
+ - **Borrower identity** = `Customers.c_borrowerId`.
102
+ - **Core.Records record IDs:** Addresses = 13, Entitlements = 191, Sales orders = 14,
103
+ Entitlement tickets = 212.
104
+
105
+ ## Frontend contract (delivered by Tanner — TRUE-79825 / 79969 / 79905, toga2-view)
106
+
107
+ The frontend UI that this guard honors was delivered separately: `components/ValidateAddressModal`,
108
+ `hooks/useValidateAddressFormViewModel`, `pages/ZipValidation`. The FE calls
109
+ `GET /addresses/validateAddress` and reads **`data.addresses.validateAddress`** typed as
110
+ `{ success, error?, address1?, address2?, city?, state?, zipCode?, country? }`. The modal shows
111
+ suggested-vs-entered when `success && address1`, and a "couldn't verify" toaster when
112
+ `success === false`. See the shared
113
+ [address-validation feature](../../../2.0/apps/_underscore/features/address-validation.md) for the
114
+ endpoint mechanics.
115
+
116
+ ## Gotchas / known issues
117
+
118
+ - **TOCTOU on the uniqueness check (accepted).** The check-then-persist dedup is **not**
119
+ lock-guarded. Two truly concurrent same-address WH purchases could both pass. Accepted for
120
+ sequential purchases; documented in code.
121
+ - **AIG carrier vs. AIG tenant.** "AIG" here is the warranty **carrier** behind Rate's Whole
122
+ Home Warranty — a different codepath from the separate `Client_Aig` / Staples Protection Plan
123
+ tenant. Do not conflate the two.
124
+ - **`ApiPayloadInterceptors` (Client_Rate) has no `phpMethod` column.** The interceptor method
125
+ is resolved **by convention** from `(prePostProcessing, httpMethod)`: `PRE+POST → prePost`,
126
+ `POST+POST → postPost` (matches the framework `[pre|post][HttpMethod]` convention).
127
+ - **`CustomRecordFields.type` enum supports only `STRING`/`NUMBER`** — `c_serviceAddressId` is
128
+ registered as `NUMBER`.
129
+ - **No `_Query` bind API here** — use `_Database::escape()` / int-casts for all interpolated
130
+ values.
131
+
132
+ ## Change history
133
+
134
+ - 2026-07-07 — Built the WH per-address purchase guard (TRUE-79533): `prePost` on
135
+ `_Model_Rate_Entitlement` hard-blocks invalid addresses and second active WH at the same
136
+ normalized address (global), adopts the carrier-normalized address onto the payload, and
137
+ disables the query cache for the committed-read dedup; `postPost` persists the validated
138
+ service address (`Entitlements.c_serviceAddressId`, `Addresses.isValidated=1`) without blocking
139
+ the AIG/email flow. Migration adds `c_serviceAddressId` + index, registers the CustomRecordField
140
+ and PRE/POST interceptors, and backfills existing WH entitlements. (mhammontree)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.283",
3
+ "version": "1.0.285",
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",