toga-ai 1.0.282 → 1.0.284

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,6 +4,7 @@
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 |
8
9
  | [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
10
  | [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 |
@@ -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)
@@ -6,6 +6,7 @@
6
6
  | [Multi-Client (Cross-Client) Data Retrieval](features/cross-client-data-retrieval.md) | A single authenticated V2 GET listing can return records across **many** clients (designed for 1000+) that the caller is entitled to, honoring **each target cli | api2/Component/Api/CrossClient/CrossClient.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php, api2/_.php, _underscore/Model/Cache/Table.php, _underscore/Model/Cache/Tables/Client.php, dbchanges2/Core/2026-06-30b - CacheClusterRegistrationAndRecordTtl.sql, dbchanges2/Cache/2026-06-30a - MultiClientCacheTables.sql |
7
7
  | [Encrypted-User-UUID Auth Handoff (/auth/encrypted-user-uuid)](features/encrypted-user-uuid-auth-handoff.md) | `POST /auth/encrypted-user-uuid` is the intended **cross-client / SSO-handoff identity mechanism**: given an encrypted `{client, user}` UUID pair, it mints a fr | api2/Component/Api/CrossClient/CrossClient.php |
8
8
  | [Language Translation Layer (audience.language + sidecar tables)](features/language-translation-layer.md) | Serves the same TOGa data (Item title/description/longDescription, expanding later) in multiple languages without forking the schema or breaking English consume | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, _underscore/Model/Core/Setting.php, _underscore/Model/Core/RecordField.php, _underscore/Model/Core/DefaultGlobalSetting.php, _underscore/Model/Client/ItemTranslation.php, dbchanges2/Client/2026-06-23a - ItemTranslations.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Core/2026-06-23a - RecordFieldsTranslationColumn.sql, dbchanges2/Core/2026-06-23b - ItemTranslationsRecord.sql |
9
+ | [Nested-relationship writes & child matching (link vs. create)](features/nested-relationship-writes.md) | When a 2.0 API write payload (`POST`/`PUT`) contains a **nested related object** (e.g. | api2/Component/Api/V2/V2.php |
9
10
  | [POST + JSON-body args for scripted APIs](features/scripted-api-post-body-args.md) | The V2 engine can run a Record Script (scripted API) for a **POST** request, and a scripted API can receive its arguments from the **JSON request body** instead | api2/Component/Api/V2/V2.php |
10
11
  | [Surface action-state via the surface=<slug> request option (M2M-safe)](features/surface-meta-option.md) | An opt-in V2 engine request option, `surface=<slug>`, that attaches per-record UI action state (`isVisible`/`isEnabled`) to a GET response **under `meta.surface | api2/Component/Api/V2/V2.php, _underscore/Model/Core/Surface.php |
11
12
  | [Tickets API (/v2/tickets)](features/tickets-api.md) | The generic ticket endpoint of the 2.0 REST API. | Component/Api/V2/V2.php |
@@ -6,8 +6,8 @@ project: API
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-06-08
10
- owners: [jcardinal]
9
+ updated: 2026-07-07
10
+ owners: [jcardinal, bala]
11
11
  files:
12
12
  - api2/Controller/Index.php
13
13
  - api2/Component/Api/V2/V2.php
@@ -34,6 +34,9 @@ framework is **pulled at deploy, not vendored**. Composer deps: `sentry/sentry`,
34
34
  **Critical rules:** every action method must return the exact envelope
35
35
  `['success' => bool, 'data' => mixed, 'errors' => array]` — never a raw string or bare array.
36
36
  On error: `success=false`, `data=null`, `errors=[...]`. Deviating breaks API consumers silently.
37
+ A **nested related object** in a write payload links to an existing record **only** by an
38
+ identifier (`uuid`); a non-unique custom field (e.g. `c_employeeXid`) is not a match key and
39
+ forces a **new-record insert** every write — link with `contact: {uuid}`.
37
40
 
38
41
  ## Dependencies
39
42
 
@@ -0,0 +1,80 @@
1
+ ---
2
+ title: "Nested-relationship writes & child matching (link vs. create)"
3
+ framework: "2.0"
4
+ repo: api2
5
+ project: API
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-07
10
+ owners: ["bala"]
11
+ files:
12
+ - api2/Component/Api/V2/V2.php
13
+ related:
14
+ - ../architecture.md
15
+ ---
16
+
17
+ ## Summary
18
+
19
+ When a 2.0 API write payload (`POST`/`PUT`) contains a **nested related object** (e.g. a unit
20
+ payload with an embedded `contact: {…}`), the api2 V2 engine decides per nested object whether to
21
+ **link an existing** related record or **create a new one**. The rule that governs this is narrow
22
+ and easy to get wrong:
23
+
24
+ > A nested related object is matched to an existing record **only** by fields flagged as
25
+ > **identifiers** (`uuid` is the identifier field). If the object contains an identifier, the
26
+ > engine **loads and links** the existing record. If it contains no identifier field, the engine
27
+ > **creates a new child record** — even if the object carries a value (like a custom field) that
28
+ > *looks* unique to a human.
29
+
30
+ Implemented in `Component/Api/V2/V2.php` (`getForeignKeyValue()` / the nested-write matching in
31
+ `processRoutePairs()`).
32
+
33
+ ## How it works
34
+
35
+ - **`uuid` is the identifier field.** A nested object of `{uuid: <existing-uuid>}` forces a
36
+ **MATCH**: the engine loads that existing record and links the parent to it — it never creates.
37
+ (The numeric internal `id` also links an existing record.)
38
+ - **A non-unique custom field is NOT a match key.** Passing a nested object that contains only a
39
+ plain custom field — e.g. `contact: {c_employeeXid: 'X208000'}` — provides **no identifier**, so
40
+ the engine has nothing to match on and **inserts a new child record every time**. It does not
41
+ matter that `c_employeeXid` is conceptually the employee's unique key; it is not flagged as an
42
+ identifier, so the engine ignores it for matching.
43
+
44
+ ## Correct pattern
45
+
46
+ To **link** an existing related record, send its identifier:
47
+
48
+ ```
49
+ "contact": { "uuid": "<existing-contact-uuid>" } // links; never creates
50
+ ```
51
+
52
+ To **create** a new related record, send its data **without** an identifier (and accept that a new
53
+ row is written). Never rely on a non-identifier field (a custom field, a name, an email) to
54
+ de-duplicate — the engine will not use it to match, and you will accumulate duplicate empty child
55
+ rows on every write.
56
+
57
+ If you need to link by a business key (like an employee XID), **resolve that key to a uuid in your
58
+ caller first** (e.g. build a `key → uuid` map), then send `{uuid}`.
59
+
60
+ ## Gotcha
61
+
62
+ - **Nested write with only a non-identifier field silently creates duplicates.** This is a
63
+ duplicate-generating footgun: each write inserts a new empty child and the parent links to a
64
+ fresh shell (or is left with a NULL FK). It compounds on any cron/integration that writes the
65
+ same entity repeatedly. Root-caused in production on the Prudential device-sync cron, which sent
66
+ `contact: {c_employeeXid}` and generated ~893k empty Contact rows (~5,600/day). See
67
+ [Prudential device import + contact linking](../../../clients/prudential/features/device-information-import-and-contact-linking.md).
68
+
69
+ ## Change history
70
+
71
+ - 2026-07-07 — Documented that api2 nested-relationship writes match an existing child **only** by
72
+ identifier (`uuid`); a non-unique custom field is not a match key and forces a new-record insert.
73
+ Correct pattern is `contact: {uuid}` (resolve business keys to uuid in the caller). Surfaced by
74
+ the Prudential "null null" contact-export incident. (bala)
75
+
76
+ ## Related docs
77
+
78
+ - [api2 architecture](../architecture.md) — the V2 CRUD engine (`processRoutePairs`).
79
+ - [Prudential device import + contact linking](../../../clients/prudential/features/device-information-import-and-contact-linking.md)
80
+ — the production incident that exposed this behavior.
@@ -6,8 +6,8 @@ project: Database Changes
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-07-02
10
- owners: [jcardinal, mhammontree]
9
+ updated: 2026-07-07
10
+ owners: [jcardinal, mhammontree, bala]
11
11
  files:
12
12
  - Core/
13
13
  - Client/
@@ -233,6 +233,17 @@ query into a temporary derived table, so the outer `DELETE` no longer "sees" a l
233
233
  its own target. Used in `2026-07-02a - FixSA133377TrackingSerialAndDuplicateIF.sql` to prune
234
234
  duplicate `ItemFulfillments` rows.
235
235
 
236
+ ### FK back-reference cycle — re-point detail rows, don't delete them
237
+
238
+ When deduping a parent (e.g. `Contacts`) that has a back-reference FK to its own detail rows
239
+ (`primaryContactEmailAddressId` → `ContactEmailAddresses`, `primaryContactPhoneNumberId` →
240
+ `ContactPhoneNumbers`), deleting the non-keeper detail rows breaks the cycle. Instead re-point
241
+ `ContactEmailAddresses`/`ContactPhoneNumbers.contactId` to the keeper (safe because `contactId`
242
+ is non-unique on those tables → no collision), which preserves every primary pointer. Detail
243
+ tables with a `UNIQUE(parentId, childId)` (e.g. `ContactAddresses`) can't be re-pointed —
244
+ null-then-delete those. Then delete the non-keeper parents via the materialized double-nested
245
+ subquery. Used in `Client_Prudential/2026-07-07 - Contact Dedup Merge.sql`.
246
+
236
247
  ## Relationship to the rest of 2.0
237
248
 
238
249
  `dbchanges2` is registered as a **2.0 core repo** (`role: core` in `registry.json`) — it is
@@ -5,7 +5,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
5
5
  ## 1.0 framework
6
6
 
7
7
  - **library** (Library) _(framework core)_ — 11 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
- - **worker** (Worker) — 13 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
8
+ - **worker** (Worker) — 14 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
9
9
  - **worker1.5** (Worker 1.5) — 0 doc(s) → [1.0/apps/worker1.5/INDEX.md](1.0/apps/worker1.5/INDEX.md)
10
10
  - **togadesk** (TOGa Desk) — 8 doc(s) → [1.0/apps/togadesk/INDEX.md](1.0/apps/togadesk/INDEX.md)
11
11
  - **togaview** (TOGa View) — 6 doc(s) → [1.0/apps/togaview/INDEX.md](1.0/apps/togaview/INDEX.md)
@@ -17,9 +17,9 @@ _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)_ — 24 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
- - **api2** (API) — 9 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
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)
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)
@@ -4,6 +4,7 @@
4
4
  |-----|-----------|---------|-------|
5
5
  | [Prudential: Dell ASN units PRE/POST interceptor (legacy key + flat tracking)](features/dell-asn-units-interceptor.md) | 2.0 | After the tracking-number bridge migration, the ASN unit route was renamed (`advance-shipping-notice-units` → `advance-shipping-notice-item-units`), so the inhe | _underscore/Model/Prudential/AdvanceShippingNotice.php, dbchanges2/Client_Prudential/2026-06-10 - AsnUnitsInterceptor.sql |
6
6
  | [Prudential: Dell LCH IOP transmissions (LCHRequestV2)](features/dell-lch-iop-transmissions.md) | 1.0 | Outbound order transmissions from the 1.0 worker tier to Dell's Lifecycle Hub (LCH) ITSM integration. | library/app/api/delllch.php, worker/crons/toga2/prudential/transmissions_to_dell.php, worker/crons/toga2/prudential_beta/transmissions_to_dell_india.php, worker/crons/toga2/prudential_beta/transmissions_to_dell_ireland.php, worker/crons/toga2/prudential_beta/transmissions_to_dell_usa.php |
7
+ | [Prudential: Device information import + unit→contact linking (import_device_information.php)](features/device-information-import-and-contact-linking.md) | 1.0 | Prudential's **device-sync** cron (`worker/crons/toga2/prudential/import_device_information.php`) pulls device/asset records (from ServiceNow / the Dell CMDB fe | worker/crons/toga2/prudential/import_device_information.php, worker/crons/toga2/prudential/backfill_unit_contacts.php, dbchanges2/Client_Prudential/2026-07-07 - Contact Dedup Merge.sql |
7
8
  | [Prudential: Service Request Regional Address Validation](features/service-request-address-validation.md) | 2.0 | The `prePost` interceptor on `_Model_Prudential_ServiceRequest` validates `deliverToAddress` fields differently depending on which Prudential regional customer | _underscore/Model/Prudential/ServiceRequest.php |
8
9
  | [Prudential Order Shipped Email — transmit_ordershipped_updates_prudential.php](features/transmit-ordershipped-email.md) | 1.0 | Cron script that transmits "Order Shipped" updates to ServiceNow (RITM) and sends a shipped notification email to the end user. | worker/crons/toga2/prudential/transmit_ordershipped_updates_prudential.php, worker/crons/toga2/prudential/transmit_closecomplete_updates_prudential.php, worker/crons/toga2/prudential/generate_sales_and_purchase_orders_from_service_requests.php, worker/crons/toga2/prudential_beta/generate_sales_and_purchase_orders_from_service_requests.php |
9
10
  | [Prudential Financial](profile.md) | 2.0 | Prudential is a TOGA client whose device-fulfillment flow is driven by **Dell** via the Dell API (`Client_Prudential.Apis.id = 2`). | |
@@ -0,0 +1,151 @@
1
+ ---
2
+ title: "Prudential: Device information import + unit→contact linking (import_device_information.php)"
3
+ framework: "1.0"
4
+ repo: worker
5
+ project: Worker
6
+ client: prudential
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-07-07
10
+ owners: ["bala"]
11
+ files:
12
+ - worker/crons/toga2/prudential/import_device_information.php
13
+ - worker/crons/toga2/prudential/backfill_unit_contacts.php
14
+ - dbchanges2/Client_Prudential/2026-07-07 - Contact Dedup Merge.sql
15
+ related:
16
+ - ../profile.md
17
+ - ../../../2.0/apps/api2/features/nested-relationship-writes.md
18
+ ---
19
+
20
+ ## Summary
21
+
22
+ Prudential's **device-sync** cron (`worker/crons/toga2/prudential/import_device_information.php`)
23
+ pulls device/asset records (from ServiceNow / the Dell CMDB feed) and PUTs them into the 2.0
24
+ platform (`Client_Prudential` via the TOGa2 API), writing `Units` and their owning `Contact`.
25
+ Each unit's contact is keyed to an employee by the custom field **`c_employeeXid`**.
26
+
27
+ This doc records how the cron links a unit to its real employee contact, the production incident
28
+ that came from getting that wrong, and the two remediation pieces (a dedup migration and a
29
+ null-contact backfill cron).
30
+
31
+ ## How it works
32
+
33
+ - The cron loads **all** existing Prudential contacts once into an in-memory
34
+ **`c_employeeXid` → keeper uuid** map (`loadPrudentialContactMap`).
35
+ - `resolvePrudentialContactUuid(<XID>)` returns the keeper contact uuid for an employee XID —
36
+ the **oldest named contact** for that XID. On a genuine miss it creates **exactly one**
37
+ contact and caches it so the same run never creates a second.
38
+ - All four unit-write payloads link the existing contact by sending **`contact: {uuid: <keeper-uuid>}`**
39
+ — an identifier the api2 write engine can match — **never** `contact: {c_employeeXid: …}`.
40
+
41
+ The keeper uuid comes from the resolved map, so every unit for a given employee links to the one
42
+ real contact and no duplicate empty contacts are created.
43
+
44
+ ## Why `contact: {uuid}` and not `contact: {c_employeeXid}`
45
+
46
+ This is the crux. In the api2 V2 write engine a nested related object is matched to an existing
47
+ record **only** by fields flagged as identifiers (`uuid`). A plain, non-unique custom field like
48
+ `c_employeeXid` is **not** a match key, so a nested `contact: {c_employeeXid: …}` object never
49
+ matches an existing contact — the engine **inserts a new (empty) Contact every time** and links
50
+ the unit to that shell. See the general behavior doc:
51
+ [2.0 api2 — Nested-relationship writes & child matching](../../../2.0/apps/api2/features/nested-relationship-writes.md).
52
+
53
+ ## The production incident (root cause)
54
+
55
+ The Prudential TOGa Supply contact export showed **"null null"** contact names and blank
56
+ phone/email/XID on ~11.6k of ~27.8k rows, and searching the export by employee name or XID
57
+ returned almost nothing.
58
+
59
+ **Root cause:** the device-sync cron sent the unit's contact as a nested object containing
60
+ **only `c_employeeXid`**. Because that field is not an identifier, api2 could not match an
61
+ existing Contact, so it **INSERTed a new empty Contact on every run** and either linked the unit
62
+ to that empty shell or left `Units.contactId` NULL. Verified in prod logs: two `PUT /units` for
63
+ the same XID seconds apart each created a **different** new contact (`dtCreated = now`,
64
+ `firstName = null`).
65
+
66
+ **Blast radius in prod:** ~**893k** Contact rows for only ~**19.5k** real employees (XID
67
+ `X208000` alone had **178k** rows), and ~**23k** Units with `contactId` NULL. The cron was
68
+ generating roughly **5,600 duplicate empty contacts/day**.
69
+
70
+ ## Remediation
71
+
72
+ Two independent pieces, run **in order**:
73
+
74
+ 1. **Cron fix (deployed to prod, live)** — the in-memory map + `contact: {uuid}` payloads above.
75
+ This **stops new junk**. It must be deployed before anything cleans up existing junk, or the
76
+ cleanup regenerates (observed on sandbox).
77
+ 2. **Dedup migration** — `dbchanges2/Client_Prudential/2026-07-07 - Contact Dedup Merge.sql`
78
+ (tested on a full prod copy; **not yet run on prod**). Removes **existing** junk: collapses
79
+ Contacts to one keeper per `c_employeeXid`. See *Dedup migration* below.
80
+ 3. **Null-contact backfill cron** — `worker/crons/toga2/prudential/backfill_unit_contacts.php`
81
+ (logic tested; **not yet run**). Links `Units` that still have `contactId` NULL, resolving the
82
+ owner from **ServiceNow by serial** (SQL cannot — a unit stores no XID of its own). See
83
+ *Null-contact backfill* below. Run **after** the dedup migration so the contact map is small.
84
+
85
+ ### Dedup migration (`2026-07-07 - Contact Dedup Merge.sql`)
86
+
87
+ Keeper per `c_employeeXid` = **oldest NAMED contact with a primary email**, else oldest named,
88
+ else oldest. Rows with blank/NULL XID are **excluded** so unrelated contacts never merge. Phases:
89
+
90
+ 1. Re-point the **16 columns** that reference `Contacts` to the keeper.
91
+ 2. **Re-point** `ContactEmailAddresses`/`ContactPhoneNumbers.contactId` to the keeper (instead of
92
+ deleting them). This resolves the `Contacts.primaryContactEmailAddressId` /
93
+ `primaryContactPhoneNumberId` back-reference **FK cycle** — `contactId` is non-unique on those
94
+ tables so no collision — and preserves every primary pointer.
95
+ 3. `ContactAddresses` has `UNIQUE(contactId, addressId)`, so it is **nulled then deleted**
96
+ (0 cross-links observed).
97
+ 4. Delete non-keeper bridge/detail rows that have no remaining back-reference.
98
+ 5. Delete non-keeper contacts via a **materialized double-nested subquery** (avoids MySQL
99
+ error 1093 "can't specify target table" on Contacts-targeting statements — the same idiom
100
+ documented in the dbchanges2 architecture doc).
101
+
102
+ Uses inline keeper subqueries — **no work table, no backup table** (the DBA takes a DB backup
103
+ separately). Sandbox result on a full prod copy: Contacts **893,379 → 19,754**; duplicate XIDs
104
+ **3,820 → 0**; named contacts preserved; SalesOrders/ServiceRequests/PurchaseOrders/Units counts
105
+ unchanged and zero now point to an empty contact; ContactEmailAddresses/PhoneNumbers preserved;
106
+ zero dangling/orphaned references.
107
+
108
+ ### Null-contact backfill (`backfill_unit_contacts.php`)
109
+
110
+ Resumable, unit-driven. For each `Unit` with `contactId` NULL it queries ServiceNow by serial
111
+ (`/gbts/v1/sn_pcaas/asset?serial_number=`) for the owner XID, resolves the contact uuid (reusing
112
+ the map logic), and PUTs `/units {serialNumber, contact:{uuid}}`. Resumable via a `Parameters`
113
+ pointer key **`SERVICE_NOW_UNIT_CONTACT_BACKFILL`** (upserts; generates a uuid4 on insert),
114
+ time-bounded per run.
115
+
116
+ **Do NOT force NULL contactId to zero.** Null-contact units are frequently **legitimate**: Dell
117
+ has not yet assigned the laptop to an employee, so the device is unassigned inventory (ServiceNow
118
+ returns no `ownedBy`). Verified read-only on the prod copy: of **22,960** null-contact units, only
119
+ **26** shipped on a Toga ASN (all 26 trace to a named order contact); the other ~22,934 never went
120
+ through a Toga order and cluster in inventory dispositions/stages (only 15 "In Use"). **Caveat:**
121
+ the device-import cron also pulls CMDB assets never ordered through Toga, so "not on an ASN" does
122
+ not by itself prove unassigned — definitive ownership is **ServiceNow-by-serial only**. A residual
123
+ of no-owner units after backfill is **expected and correct**.
124
+
125
+ ## Gotchas
126
+
127
+ - **Never link a related record by a non-identifier custom field.** `c_employeeXid` is not a match
128
+ key; only `uuid` (or the numeric internal id) links an existing child. Passing the custom field
129
+ alone silently creates a new empty record on every write.
130
+ - **Ordering is load-bearing:** deploy the cron fix → run the dedup migration → run the backfill.
131
+ Running dedup before the cron fix regenerates duplicates.
132
+ - **No `continue`** in the helpers added to this cron (team standard); the new backfill file is
133
+ tab-indented (the legacy cron body is space-indented — only the added helpers were converted).
134
+ - API auth uses the `App_Api_Toga2::CLIENT_UUID_PRUDENTIAL` / `API_UUID_PRUDENTIAL` /
135
+ `API_SECRET_PRUDENTIAL` constants (names only — values live in config, not here).
136
+
137
+ ## Change history
138
+
139
+ - 2026-07-07 — Root-caused the "null null" export incident to nested `contact: {c_employeeXid}`
140
+ writes (~893k empty Contacts, ~23k NULL-contact Units, ~5,600 dupes/day). Fixed the cron to
141
+ build a `c_employeeXid → keeper uuid` map and send `contact: {uuid}` (deployed to prod). Built
142
+ the Contact dedup merge migration (tested on prod copy, not yet run) and a ServiceNow-by-serial
143
+ null-contact backfill cron (logic tested, not yet run). Confirmed NULL-contact units are often
144
+ legitimate unassigned inventory — do not force to zero. (bala)
145
+
146
+ ## Related docs
147
+
148
+ - [Prudential profile](../profile.md)
149
+ - [2.0 api2 — Nested-relationship writes & child matching](../../../2.0/apps/api2/features/nested-relationship-writes.md)
150
+ — the general api2 behavior this incident exposed.
151
+ - dbchanges2 architecture — *Self-referencing DELETE* idiom used by the dedup migration.
@@ -12,13 +12,14 @@ project: _Underscore
12
12
  client: prudential
13
13
  type: profile
14
14
  status: active
15
- updated: 2026-07-06
16
- owners: ["jcardinal", "rgirish"]
15
+ updated: 2026-07-07
16
+ owners: ["jcardinal", "rgirish", "bala"]
17
17
  files: []
18
18
  related:
19
19
  - features/dell-asn-units-interceptor.md
20
20
  - features/service-request-address-validation.md
21
21
  - features/dell-lch-iop-transmissions.md
22
+ - features/device-information-import-and-contact-linking.md
22
23
  ---
23
24
 
24
25
  ## Summary
@@ -60,3 +61,5 @@ order-status transmissions.
60
61
  - Dell LCH IOP transmissions (LCHRequestV2, outbound 1.0).
61
62
  - 2.0 _underscore: Tracking-Number Bridge Migration.
62
63
  - features/transmit-ordershipped-email.md
64
+ - features/device-information-import-and-contact-linking.md — device-sync cron, unit→contact
65
+ linking, the "null null" contact-export incident + dedup/backfill remediation.
@@ -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.282",
3
+ "version": "1.0.284",
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",