toga-ai 1.0.419 → 1.0.421

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ project: AI-BDR
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-21
9
+ updated: 2026-07-23
10
10
  owners: [tcox]
11
11
  files:
12
12
  - bdr/PLAN.md
@@ -374,24 +374,33 @@ PRODUCTION credential set (`TOGA_API_BASE_URL = https://api.togahub.com/v2`).
374
374
  - **Restart the dev server after creating `.env.local`** — Next reads env files only at
375
375
  server startup. `.env.*` is gitignored in BDR.
376
376
 
377
- ## Deploy status (AWS Amplify — app EXISTS, first build shipping)
377
+ ## Deploy status (AWS Amplify — LIVE + verified 2026-07-23)
378
378
 
379
- **The Amplify app now exists (2026-07-21):** branch `_dev-sandbox` deploys to
380
- **`dev-sandbox.d65bvvjrww97g.amplifyapp.com`** (supersedes the prior "no Amplify app yet"
381
- status). Go-live tracking: ClickUp **868kdf4tj "BDR - create amplify and sandbox route"**
379
+ **The BDR site is LIVE and verified (2026-07-23):** branch `_dev-sandbox` (Amplify default
380
+ domain `d65bvvjrww97g.amplifyapp.com`) serves the funnel at the custom domain
381
+ **https://bdr.dev.sandbox.togatech.com/** — the landing page returns `200` and
382
+ `GET /api/contact?hsContactId=131770626674` returns `200` with the contact JSON (server env is
383
+ now present at runtime). This supersedes the 2026-07-21 "first build shipping / awaiting console
384
+ vars" status. Go-live tracking: ClickUp **868kdf4tj "BDR - create amplify and sandbox route"**
382
385
  (urgent, Sprint 82). (ClickUp showed Alex Peterson as assignee; the team said Jeff picked it
383
386
  up — confirm the real owner before assuming.)
384
387
 
385
388
  - **CRITICAL — the build-spec must WRITE the console env vars into `.env.production` at build
386
- time (Amplify Next-SSR recipe).** The first deployment surfaced a build-spec bug: the old
387
- `amplify.yml` only had branch cases for `_production` / `_beta`, each doing
388
- `cp .env.production .env.local` — which expects a **committed** `.env` file that (correctly)
389
- does not exist (secrets are never committed). **No branch case matched `_dev-sandbox` at all**,
390
- so every deploy shipped with **ZERO server env vars**. Amplify console env vars exist only at
391
- **build time**; the Next SSR **runtime** reads `.env.production` from the build artifact — so the
392
- build must materialize them. Fix (commit `e17d804`): during build,
393
- `env | grep -E '^(HUBSPOT_|TOGA_|NEXT_PUBLIC_|RATE_LIMIT_)' >> .env.production`. The same broken
394
- `cp` pattern would have bitten the `_production` launch too.
389
+ time (Amplify Next-SSR recipe). RESOLVED + verified live 2026-07-23.** Root cause, now confirmed
390
+ with git evidence: the old `amplify.yml` was inherited from the old `info` repo (authored by
391
+ Rohan) and did `cp .env.production .env.local` per branch, with cases only for
392
+ `_production` / `_beta`. That worked in `info` **only because the info repo COMMITS its real
393
+ `.env` files to git** (`git ls-files` shows `.env.beta`, `.env.local`, `.env.production` tracked,
394
+ containing real credentials — see the security gotcha below). The BDR repo correctly gitignores
395
+ all `.env` files (only `.env.example` is tracked, per team git rules), so the `cp` had nothing to
396
+ copy **and** no branch case matched `_dev-sandbox` — every deploy shipped with **ZERO server env
397
+ vars**. Amplify console env vars exist only at **build time**; the Next SSR **runtime** reads
398
+ `.env.production` from the build artifact — so the build must materialize them. Fix (commit
399
+ `e17d804` on `_dev-sandbox`, `amplify.yml` build phase, before `npm run build`):
400
+ `env | grep -E '^(HUBSPOT_|TOGA_|NEXT_PUBLIC_|RATE_LIMIT_)' >> .env.production || true` — the
401
+ AWS-documented pattern for Next SSR on Amplify. It is **branch-agnostic** (no per-branch case to
402
+ miss), and secrets stay in the Amplify console, never in git. The same broken `cp` pattern would
403
+ have bitten the `_production` launch too.
395
404
  - **Symptom chain when the env vars are missing (what "no vars" looks like end-to-end):**
396
405
  page-prime's HubSpot fetch throws → lead ref comes back empty → the **anonymous-visit guard
397
406
  notice shows even with a valid `?hsContactId`** → `GET /api/contact` returns
@@ -400,18 +409,35 @@ up — confirm the real owner before assuming.)
400
409
  - **OPERATIONAL — env vars saved AFTER a build starts do NOT reach that build.** They are baked in
401
410
  at build time, so after adding/changing console vars you must **"Redeploy this version"** for
402
411
  them to take effect — a fresh save alone does nothing for an in-flight or already-built deploy.
412
+ (This bit us 2026-07-23: the first post-fix build ran **before** the console vars existed and
413
+ still `500`'d, which looked like the fix had failed — it was just the pre-vars build.)
414
+ - **OPERATIONAL — a buildspec saved in the console Build settings OVERRIDES the repo `amplify.yml`.**
415
+ If an `amplify.yml` change in the repo seems to have no effect, check **Console → Build settings**
416
+ for an inline buildspec shadowing it, and reconcile them before assuming the repo change is wrong.
417
+ - **OPERATIONAL — adding a custom domain does NOT trigger a rebuild.** Wiring
418
+ `bdr.dev.sandbox.togatech.com` to the app does not by itself redeploy; if the running build
419
+ predates a needed env/spec change, run **"Redeploy this version"** separately.
403
420
  - **Amplify must be configured for SSR hosting (NOT static export)** — the app is server-rendered
404
421
  (server components, route handlers, and the `proxy.ts` edge proxy all require the SSR/compute
405
422
  runtime). Point the app at branch `_dev-sandbox`.
406
- - **7 env vars go in the Amplify console** (never the values — **values live in `info/.env.local`**,
407
- the production credential set): the 5 server-side ones `HUBSPOT_ACCESS_TOKEN`,
408
- `TOGA_API_BASE_URL`, `TOGA_CLIENT_ID`, `TOGA_CLIENT_API_UUID`, `TOGA_CLIENT_API_SECRET`, plus
409
- `TOGA_WORKER_URL` (the fast-dial gate — unset ⇒ cron-only) and `NEXT_PUBLIC_GA_MEASUREMENT_ID`
410
- (GA4). See the parity audit for the full 7-var accounting.
411
- - **Status at capture:** env fix pushed (`e17d804` on `_dev-sandbox`); awaiting the console vars +
412
- a green build for the first successfully deployed page-prime. The primary-phone self-heal (commit
413
- `4ac579e`, see the phone-less bug above) is already on `_dev-sandbox`, so the first good build
414
- ships that fix too.
423
+ - **7 env vars go in the Amplify console** (never the secret **values** — those **live in
424
+ `info/.env.local`**, the production credential set). Console recipe that completed the fix
425
+ (verified working 2026-07-23): **AWS Amplify console → BDR app → Hosting → Environment variables
426
+ → Manage variables**, applied to **all branches**. The 7: the 4 secrets `HUBSPOT_ACCESS_TOKEN`,
427
+ `TOGA_CLIENT_ID`, `TOGA_CLIENT_API_UUID`, `TOGA_CLIENT_API_SECRET` (values only in the console +
428
+ `info/.env.local`, never here); plus the non-secret config `TOGA_API_BASE_URL`
429
+ (`https://api.togahub.com/v2`), `TOGA_WORKER_URL` (`https://worker.togahub.com/` — new vs. info;
430
+ enables fast dial), and `NEXT_PUBLIC_GA_MEASUREMENT_ID` (`G-0BE4RFNLY0`, a public GA4 id).
431
+ `TOGA_PUBLIC_TOKEN_URL` and `NEXT_PUBLIC_ENV` are dead even in `info` and are intentionally NOT
432
+ carried over. See the parity audit for the full accounting.
433
+ - **Status (2026-07-23): LIVE and verified.** After the console vars were saved and the version was
434
+ redeployed, the deployed page-prime works: `https://bdr.dev.sandbox.togatech.com/` returns `200`
435
+ and `GET /api/contact?hsContactId=131770626674` returns `200` with contact JSON. The primary-phone
436
+ self-heal (commit `4ac579e`) is live, and fast dial is now wired in the deployed env
437
+ (`TOGA_WORKER_URL` set). **Still pending (deferred to next session):** the deployed **Call Now**
438
+ smoke test — which is also **fast dial's first live run** end-to-end (local testing never
439
+ exercised `TOGA_WORKER_URL`, so the deployed Call Now is the first time the fast-dial POST fires
440
+ in a real deploy).
415
441
  - **Phase 8 WAF pairing.** `BDR/PLAN.md` Phase 8 specifies a **per-IP rate rule on the CloudFront
416
442
  distribution / WAF** that pairs with the in-app token-bucket limiter (the app limiter is
417
443
  defense-in-depth only; the edge WAF rule is the authoritative control — see the enumeration
@@ -514,6 +540,15 @@ up — confirm the real owner before assuming.)
514
540
  happen inside the route handler. See "Request-a-call is fully server-side" above. And note
515
541
  setting `dtNextContactRequested` does not guarantee a call — the worker2 dialer eligibility
516
542
  SQL decides (a phone-less contact is silently never dialed; see that section).
543
+ - **HIGH (security) — the old `info` repo COMMITS real production credentials to git.** `info`'s
544
+ `.env.beta` / `.env.local` / `.env.production` are **tracked** files (confirmed via `git ls-files`)
545
+ containing the real **HubSpot access token** and **Toga API client secret**. Those credentials are
546
+ in git history and must be treated as **compromised → rotate**. The AI-BDR credential-rotation
547
+ ticket now covers the HubSpot token + Toga API secret **in addition to** the hardcoded Vapi bearer
548
+ at `worker2/Worker/Ai/Bdr/Vapi.php:6` (see
549
+ `../../worker2/features/vapi-webhook-handler.md`). BDR itself correctly gitignores all `.env` files
550
+ — this is an `info`-repo problem that BDR touches only because it sources its local-dev values from
551
+ `info/.env.local`. Never record credential **values** in the KB — location only.
517
552
  - **Standing rule: never add more PII fields to `ClientLeadRef`.** Phone is the one
518
553
  deliberate exception (visitor's own number, shown back to them, never logged). Every field
519
554
  on `ClientLeadRef` crosses to the public client and is exposed to the enumeration risk
@@ -554,6 +589,22 @@ up — confirm the real owner before assuming.)
554
589
  harness; pull it with `npx toga-ai`.
555
590
 
556
591
  ## Change history
592
+ - 2026-07-23 — **BDR site is LIVE + verified** (day 3 of go-live). RESOLVED the deployed 500
593
+ "Failed to fetch contact" (missing server env at runtime): confirmed the full root cause with git
594
+ evidence — the inherited `amplify.yml` (from the old `info` repo, which COMMITS its real `.env`
595
+ files to git) did `cp .env.production .env.local` with cases only for `_production`/`_beta`, but
596
+ BDR correctly gitignores `.env`, so the `cp` copied nothing and `_dev-sandbox` had no case →
597
+ zero server env vars per deploy. Fix (commit `e17d804`, branch-agnostic): build-time
598
+ `env | grep -E '^(HUBSPOT_|TOGA_|NEXT_PUBLIC_|RATE_LIMIT_)' >> .env.production || true`. Recorded
599
+ the exact **Amplify console recipe** that completed it (Hosting → Environment variables → Manage
600
+ variables, all branches; the 7 vars incl. new `TOGA_WORKER_URL=https://worker.togahub.com/`;
601
+ `TOGA_PUBLIC_TOKEN_URL`/`NEXT_PUBLIC_ENV` intentionally dropped) and two new operational gotchas
602
+ (a console buildspec OVERRIDES the repo `amplify.yml`; adding a custom domain does NOT trigger a
603
+ rebuild). Verified live: `https://bdr.dev.sandbox.togatech.com/` = 200 and
604
+ `GET /api/contact?hsContactId=131770626674` = 200 contact JSON. Added a HIGH security gotcha that
605
+ the old `info` repo commits real HubSpot/Toga credentials to git (treat as compromised; rotation
606
+ ticket now covers them alongside the worker2 Vapi bearer). STILL PENDING (deferred): the deployed
607
+ **Call Now** smoke test = fast dial's first live run end-to-end. (tcox)
557
608
  - 2026-07-21 — **First Amplify deployment** (app now EXISTS: branch `_dev-sandbox` →
558
609
  `dev-sandbox.d65bvvjrww97g.amplifyapp.com`, superseding the "not deployed yet" status).
559
610
  DISCOVERED + FIXED (`amplify.yml`, commit `e17d804`) an env-var build-spec bug: the old spec only
@@ -180,6 +180,15 @@ its own header.)
180
180
  (`Core/2026-05-08 … CronJobs_WorkerCleanup_insert`) used `UUID()` — that is a known violation,
181
181
  not a precedent to copy. First applied correctly in
182
182
  `Core/2026-07-21b - Insert - SprintLockScheduled CronJob.sql`.
183
+ 7. **Guard every registration/seed insert with `NOT EXISTS` (or `INSERT IGNORE`).** Rows that
184
+ register platform behavior — `ApiPayloadInterceptors`, `Core.RecordFields`/`CustomRecordFields`,
185
+ cron jobs, ACL rows — must be inserted only if not already present. An **unguarded** registration
186
+ is not merely non-idempotent: on a fresh environment it can run **alongside a second migration
187
+ that registers the same behavior**, producing a **double registration** (e.g. two interceptor
188
+ rows for the same record → the interceptor fires twice). A real instance: the Rate WH
189
+ `prePost` interceptor is registered unguarded in `Client_Rate/2026-07-15 - PreInterceptor.sql`
190
+ (record 191, `minDepth NULL`); a second guarded registration would have double-fired it. See
191
+ `clients/rate/features/whole-home-warranty-purchase-guard.md`.
183
192
 
184
193
  ## Bulk data loads — batch, and stage large sets in a temp table
185
194
 
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-21
9
+ updated: 2026-07-23
10
10
  owners: [snaredla, tcox]
11
11
  files:
12
12
  - worker2/Worker/Vapi.php
@@ -239,6 +239,10 @@ The outbound dialer `Worker/Ai/Bdr/Vapi.php` (**line 6**) declares the Vapi API
239
239
  rotate it**, and move it into the `[vapi]` section of the worker2 `Config/*.ini` files alongside the
240
240
  other Vapi secrets (the pattern the rest of the integration already uses). Config, never source, is
241
241
  the home for this credential. (Token value is deliberately not recorded here — location only.)
242
+ **Scope broadened 2026-07-23:** the AI-BDR credential-rotation ticket now also covers the
243
+ **HubSpot access token** and **Toga API client secret** that are committed to git in the old `info`
244
+ repo's tracked `.env.*` files — see the security gotcha in
245
+ [ai-bdr web-funnel-app.md](../../ai-bdr/features/web-funnel-app.md).
242
246
 
243
247
  ## Data model
244
248
 
@@ -267,6 +271,11 @@ status. See the `ai-bdr` skill / `_Model_True_ContactAttempt` for the full field
267
271
  - [VAPI Integration — assistants, tools, structured output](../../ai-bdr/features/vapi-integration.md)
268
272
 
269
273
  ## Change history
274
+ - 2026-07-23 — Broadened the SECURITY credential-rotation gotcha: the AI-BDR rotation ticket now
275
+ also covers the **HubSpot access token** and **Toga API client secret** committed to git in the
276
+ old `info` repo's tracked `.env.*` files (treat as compromised), alongside the hardcoded Vapi
277
+ bearer at `Worker/Ai/Bdr/Vapi.php:6`. Cross-linked to the ai-bdr web-funnel-app security gotcha.
278
+ (tcox)
270
279
  - 2026-07-21 — Full call loop **proven end-to-end in production** on the real campaign 2
271
280
  (`26.05 - AI BDR - Ryan Nitti`): UI submit → funnel API → dialer → Vapi call → webhook write-back.
272
281
  Live-verified the documented behavior: the `SCHEDULE_CALLBACK` → `c_callOutcome=CALLBACK_SCHEDULED`
@@ -8,5 +8,5 @@
8
8
  | [Rate SAML SSO](features/saml-sso.md) | 2.0 | Rate uses Azure AD as its IdP (`login.rate.com`). | _underscore/Model/Rate/ClientAuthentication.php, saml/Controller/Index.php, toga2-view/src/hooks/useAuthenticationFlow.ts |
9
9
  | [Service Card Entitlement Display](features/service-card-entitlements.md) | 2.0 | Rate's home and services pages display one service card per purchased entitlement. | src/components/ServiceCard/ServiceCard.tsx, src/components/ServiceCard/index.ts, src/hooks/useBundleServices.ts, src/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 | > **⚠ BETA-VERIFIED, NOT IN PRODUCTION (as of 2026-07-17).** TRUE-79533 is now merged to > `_beta` (both `_underscore` and `dbchanges2`), applied to the beta `C | _underscore/Model/Rate/Entitlement.php, _underscore/Model/Client/Address.php, dbchanges2/Client_Rate/2026-07-07a - WholeHomeWarrantyPerAddressGuard.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, test/@Mark/Rate/verify_wholehome_per_address_guard.php |
12
12
  | [Rate](profile.md) | 2.0 | Rate is a mortgage/lending client. | |
@@ -6,12 +6,14 @@ project: _Underscore
6
6
  client: rate
7
7
  type: client-feature
8
8
  status: draft
9
- updated: 2026-07-17
9
+ updated: 2026-07-23
10
10
  owners: [mhammontree]
11
11
  files:
12
12
  - _underscore/Model/Rate/Entitlement.php
13
+ - _underscore/Model/Client/Entitlement.php
13
14
  - _underscore/Model/Client/Address.php
14
- - dbchanges2/Client_Rate/2026-07-07a - WholeHomeWarrantyPerAddressGuard.sql
15
+ - dbchanges2/Client/2026-07-22a - EntitlementServiceAddressId.sql
16
+ - dbchanges2/Core/2026-07-22a - EntitlementServiceAddressIdField.sql
15
17
  - test/@Mark/Rate/verify_wholehome_per_address_guard.php
16
18
  related:
17
19
  - clients/rate/profile.md
@@ -19,14 +21,15 @@ related:
19
21
  - ../../../2.0/apps/_underscore/features/address-validation.md
20
22
  ---
21
23
 
22
- > **⚠ BETA-VERIFIED, NOT IN PRODUCTION (as of 2026-07-17).** TRUE-79533 is now merged to
23
- > `_beta` (both `_underscore` and `dbchanges2`), applied to the beta `Client_Rate` DB, deployed,
24
- > and **PM Paulina successfully tested the Whole Home Warranty purchase flow on beta — it works.**
25
- > It is still **not** merged to `_production`: the `_underscore` + `dbchanges2` `TRUE-79533` PRs to
26
- > `_production` remain the open path, and the deliberate production promotion is the only remaining
27
- > step. An earlier belief that it "shipped weeks ago" is incorrect — TRUE-79251 (purchase emails)
28
- > shipped, this guard did not. See the **shared-interceptor merge hazard** gotcha below and the
29
- > **beta unblock procedure** note before merging to `_production`.
24
+ > **⚠ DEPLOYING beta→production, NOT YET PROD-VERIFIED (as of 2026-07-23).** TRUE-79533 is
25
+ > beta-verified (PM Paulina tested the WH purchase flow on beta) and is now being promoted to
26
+ > `_production`. **The service-address field was redesigned since the beta build:** it is no
27
+ > longer a Rate-custom `c_serviceAddressId` field — it is now a **standard** `serviceAddressId`
28
+ > FK on the **base** `_Model_Client_Entitlement` (see *How it works* and *Migration* below). Prod
29
+ > launches clean (no pre-existing WH entitlements), so **no backfill ships**. Treat everything
30
+ > below as forward-looking until the production purchase flow is verified. See the
31
+ > **shared-interceptor merge hazard** gotcha and the **dangling-pin** / **unguarded prod
32
+ > interceptor** gotchas before merging to `_production`.
30
33
 
31
34
  ## Summary
32
35
 
@@ -50,7 +53,7 @@ consistent with `resolvePurchaseProduct`), the guard:
50
53
  `hasActiveWarrantyAtAddress()`.
51
54
 
52
55
  `postPost` then persists the validated service address after save (sets `Addresses.isValidated=1`
53
- and `Entitlements.c_serviceAddressId`), wrapped so it never blocks the existing AIG-contract /
56
+ and `Entitlements.serviceAddressId`), wrapped so it never blocks the existing AIG-contract /
54
57
  confirmation-email flow.
55
58
 
56
59
  Ticket: TRUE-79533. Business rule owner: PM Paulina.
@@ -64,9 +67,11 @@ Ticket: TRUE-79533. Business rule owner: PM Paulina.
64
67
  - **Address line2 distinguishes units.** A landlord's apartment units are distinct addresses:
65
68
  2A / 2B / 2C = three separate WHs. Uniqueness keys on the full normalized address including
66
69
  line2.
67
- - **Persist the validated address ON the entitlement** (new `c_serviceAddressId` FK) rather
68
- than deriving it from the fragile contact-primary-address path, and **backfill** existing WH
69
- entitlements so the global rule covers production data already present.
70
+ - **Persist the validated address ON the entitlement** (via the `serviceAddressId` FK) rather
71
+ than deriving it from the fragile contact-primary-address path. **No backfill ships** —
72
+ production launches with no pre-existing WH entitlements, so there is nothing to backfill (the
73
+ earlier beta build carried an idempotent backfill; it was dropped with the standard-field
74
+ redesign).
70
75
 
71
76
  ## How it works
72
77
 
@@ -78,25 +83,54 @@ Ticket: TRUE-79533. Business rule owner: PM Paulina.
78
83
  the normalized `address1/address2/city/state/zipCode` back onto the payload.
79
84
  4. `hasActiveWarrantyAtAddress()` joins `Entitlements` → `Items` (title LIKE `%warranty%`, with
80
85
  WH `Items.id = 3` as an OR fallback) → **active** `Subscriptions` (`isActive = 1 AND
81
- dateCancelled IS NULL`) → `Addresses` on the new `c_serviceAddressId` column. A match → block
82
- the second active WH.
86
+ dateCancelled IS NULL`) → `Addresses`. A match → block the second active WH.
87
+ - **The address is resolved via `COALESCE(serviceAddressId, contact's primary address)`**
88
+ (CodeRabbit hardening). When `serviceAddressId` is set it wins; when it is NULL the query
89
+ falls back to the contact's primary address (LEFT JOIN `Contacts` → `ContactAddresses`).
90
+ This means an **unpinned** WH — e.g. one whose `postPost` pin silently failed, leaving
91
+ `serviceAddressId` NULL — is **still caught** by the dedup. The fallback was chosen over
92
+ making the `postPost` pin throw, preserving the "never block the save" design.
83
93
  - **The dedup read disables the query cache** (`_Database::useQueryCache(false)`, restored in
84
94
  a `finally`) so it sees rows committed by a just-prior purchase rather than a stale cached
85
95
  result set.
86
96
  5. **`postPost`.** After save, persists the validated service address: sets
87
- `Addresses.isValidated = 1` and writes `Entitlements.c_serviceAddressId`. This block is
97
+ `Addresses.isValidated = 1` and writes `Entitlements.serviceAddressId`. This block is
88
98
  wrapped so a failure never interrupts the AIG-contract creation or the confirmation email
89
99
  (see the [AIG contract creation doc](aig-contract-creation.md) — the same `postPost`).
90
100
  6. All SQL uses `_Database::escape()` / int-casts — `_Query` has **no bind API** in this path.
91
101
 
92
- ## Migration (`dbchanges2/Client_Rate/2026-07-07a - WholeHomeWarrantyPerAddressGuard.sql`)
102
+ ## Field & model: `serviceAddressId` is now a STANDARD field (reviewer Jeff)
93
103
 
94
- - Adds `Entitlements.c_serviceAddressId INT NULL` + an index.
95
- - Guarded (`NOT EXISTS`) registration of `CustomRecordFields` for `c_serviceAddressId`
96
- (recordId 191, type **NUMBER**).
97
- - Guarded registration of the `ApiPayloadInterceptors` PRE/POST rows.
98
- - A one-time, idempotent backfill of `c_serviceAddressId` for existing WH entitlements, sourced
99
- from `Contacts` → `ContactAddresses` → `Addresses`.
104
+ The service-address field was **promoted from a Rate-custom field to a platform standard field**
105
+ during backend review (Jeff). This is the single biggest change since the beta build:
106
+
107
+ - **Model.** `serviceAddressId` is declared as an **FK on the BASE `_Model_Client_Entitlement`**
108
+ (`_underscore/Model/Client/Entitlement.php`) — `[FIELD_FOREIGNKEY, FIELDOPT_FOREIGNKEY_MODEL =>
109
+ '\_Model_Client_Address']` — not on `_Model_Rate_Entitlement`. The old `c_serviceAddressId`
110
+ declaration was removed from the Rate subclass. Because api2 resolves the related Addresses
111
+ model from the FK declaration, declaring it as a proper FK (vs. a plain integer) is what lets
112
+ the frontend/API traverse to the address.
113
+ - **Core RecordField (standard, not custom).** Registered in **`Core.RecordFields`** on the
114
+ Entitlements record (**id 191**): type **NUMBER**, `isIdentifier 0`, `childPolicy MATCH`,
115
+ `precision 0` — mirroring sibling FKs `saleItemId` / `vendorId`. Note `Core.RecordFields` has
116
+ **no `foreignRecordId` column**; FK resolution is model-side (the model declaration above),
117
+ not in the RecordField row.
118
+
119
+ ## Migrations (standard-field fan-out — replaces the deleted Rate-only migration)
120
+
121
+ - **`dbchanges2/Client/2026-07-22a - EntitlementServiceAddressId.sql`** — a **`Client/` fan-out**
122
+ (every tenant's `Client_<Tenant>.Entitlements`) adding `serviceAddressId INT UNSIGNED` with
123
+ index `idx_Entitlements_serviceAddressId`, positioned `AFTER subscriptionId`. Because the field
124
+ lives on the **base** model, **every** client's Entitlements table must have the column, so it
125
+ is a `Client/` fan-out, not a `Client_Rate/` change.
126
+ - **`dbchanges2/Core/2026-07-22a - EntitlementServiceAddressIdField.sql`** — registers the
127
+ `Core.RecordFields` row for `serviceAddressId` on record 191, guarded (`NOT EXISTS`).
128
+ - **Deploy order matters:** the column migration must reach **all tenants before** the
129
+ `_underscore` code that references `serviceAddressId` — otherwise every client breaks.
130
+ - **`dbchanges2/Client_Rate/2026-07-07a - WholeHomeWarrantyPerAddressGuard.sql` is DELETED.** Its
131
+ column + `CustomRecordFields` moved to the standard field above; its `ApiPayloadInterceptors`
132
+ registration was redundant (see the **unguarded prod interceptor** gotcha) and its backfill was
133
+ dropped (prod launches clean).
100
134
 
101
135
  ## Rate data model (prod-verified 2026-07-07)
102
136
 
@@ -105,9 +139,10 @@ Ticket: TRUE-79533. Business rule owner: PM Paulina.
105
139
  - **Active-subscription signal is on `Subscriptions`** (`isActive` tinyint default 1,
106
140
  `dateCancelled`, `dateEnd`, `dtRequestToCancel`). `Entitlements` has **no `isActive`**.
107
141
  - **Entitlements had no address linkage before this ticket.** `SalesOrders` has
108
- `shipToAddressId`/`billToAddressId` but they are NULL for Rate. Before `c_serviceAddressId`,
142
+ `shipToAddressId`/`billToAddressId` but they are NULL for Rate. Before `serviceAddressId`,
109
143
  the address was reachable only via `Entitlement.contactId` → `Contacts.primaryContactAddressId`
110
- → `ContactAddresses.addressId` → `Addresses`.
144
+ → `ContactAddresses.addressId` → `Addresses` (this is exactly the COALESCE fallback path the
145
+ dedup still uses when `serviceAddressId` is NULL).
111
146
  - **Borrower identity** = `Customers.c_borrowerId`.
112
147
  - **Core.Records record IDs:** Addresses = 13, Entitlements = 191, Sales orders = 14,
113
148
  Entitlement tickets = 212.
@@ -186,13 +221,42 @@ live carrier waterfall is opt-in via `RUN_LIVE=1` (defaults off). Verified **18/
186
221
  - **`ApiPayloadInterceptors` (Client_Rate) has no `phpMethod` column.** The interceptor method
187
222
  is resolved **by convention** from `(prePostProcessing, httpMethod)`: `PRE+POST → prePost`,
188
223
  `POST+POST → postPost` (matches the framework `[pre|post][HttpMethod]` convention).
189
- - **`CustomRecordFields.type` enum supports only `STRING`/`NUMBER`** — `c_serviceAddressId` is
190
- registered as `NUMBER`.
224
+ - **Dedup is defeated by a DANGLING pin (not just a NULL one).** The COALESCE fallback covers a
225
+ **NULL** `serviceAddressId`, but if `serviceAddressId` points at a **deleted `Addresses` row**,
226
+ the join drops that entitlement and it becomes **invisible to the guard** — a duplicate WH slips
227
+ through. On **beta this actually happened** through test-data churn: one reused test contact
228
+ (`contactId 8`) with addresses created/deleted per purchase left dangling pins, so **manual
229
+ repeat-purchase testing on beta is unreliable**. Test the dedup with a **fresh contact** or the
230
+ deterministic `verify_wholehome_per_address_guard.php` script. Prod launches clean (no
231
+ pre-existing WH entitlements), so no dangling-pin backfill is needed.
232
+ - **The WH `prePost` interceptor is ALREADY registered in production — via an UNGUARDED insert.**
233
+ `dbchanges2/Client_Rate/2026-07-15 - PreInterceptor.sql` (author rgirish97) registers the
234
+ record-191 PRE/POST interceptor with `minDepth NULL` and **no `NOT EXISTS` guard**. This is why
235
+ the deleted `2026-07-07a` interceptor registration was redundant — and why keeping it would have
236
+ been a latent hazard: a fresh environment running both would **double-register**, firing
237
+ `prePost` twice. **Recommend adding a `NOT EXISTS` guard to the `2026-07-15` file.**
238
+ - **`Core.RecordFields` has no `foreignRecordId` column** — FK resolution for `serviceAddressId`
239
+ is entirely **model-side** (the `_Model_Client_Entitlement` FK declaration). The RecordField row
240
+ only carries type/identifier/childPolicy/precision, mirroring sibling FKs `saleItemId`/`vendorId`.
191
241
  - **No `_Query` bind API here** — use `_Database::escape()` / int-casts for all interpolated
192
242
  values.
193
243
 
194
244
  ## Change history
195
245
 
246
+ - 2026-07-23 — **Service-address field promoted CUSTOM → STANDARD** (TRUE-79533, reviewer Jeff)
247
+ and CodeRabbit hardenings, deploying beta→prod. `c_serviceAddressId` on `_Model_Rate_Entitlement`
248
+ is gone; `serviceAddressId` is now an FK on the **base** `_Model_Client_Entitlement`, registered
249
+ as a standard `Core.RecordFields` row on record 191 (mirrors `saleItemId`/`vendorId`; no
250
+ `foreignRecordId` — FK resolution is model-side). Migrations changed to a `Client/` fan-out
251
+ (`2026-07-22a` adds the column to every tenant) + `Core/2026-07-22a` (RecordField); the Rate-only
252
+ `Client_Rate/2026-07-07a` was **deleted** (column/field moved to standard, its interceptor
253
+ registration redundant, backfill dropped — prod launches clean). Guard hardened: `serviceAddressId`
254
+ declared a proper FK so api2 resolves Addresses; `hasActiveWarrantyAtAddress()` now resolves via
255
+ `COALESCE(serviceAddressId, contact primary address)` so an unpinned WH is still deduped. Recorded
256
+ the **dangling-pin** gotcha (deleted-address pin makes an entitlement invisible to the dedup;
257
+ beta test-data churn on `contactId 8`) and the **unguarded prod interceptor** gotcha
258
+ (`Client_Rate/2026-07-15 - PreInterceptor.sql` registers record-191 PRE/POST with no `NOT EXISTS`
259
+ → double-register risk; add a guard). (mhammontree)
196
260
  - 2026-07-17 — **Beta-verified** (TRUE-79533): corrected code merged to `_beta` (`_underscore` +
197
261
  `dbchanges2`), `2026-07-07a` migration applied to the beta `Client_Rate` DB, beta redeployed, and
198
262
  **PM Paulina successfully tested the WH purchase flow on beta.** Recorded the **beta unblock
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.419",
3
+ "version": "1.0.421",
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",