toga-ai 1.0.282 → 1.0.283
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.
- package/knowledge/2.0/apps/api2/INDEX.md +1 -0
- package/knowledge/2.0/apps/api2/architecture.md +5 -2
- package/knowledge/2.0/apps/api2/features/nested-relationship-writes.md +80 -0
- package/knowledge/2.0/apps/dbchanges2/architecture.md +13 -2
- package/knowledge/INDEX.md +2 -2
- package/knowledge/clients/prudential/INDEX.md +1 -0
- package/knowledge/clients/prudential/features/device-information-import-and-contact-linking.md +151 -0
- package/knowledge/clients/prudential/profile.md +5 -2
- package/package.json +1 -1
|
@@ -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-
|
|
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-
|
|
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
|
package/knowledge/INDEX.md
CHANGED
|
@@ -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) —
|
|
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)
|
|
@@ -19,7 +19,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
19
19
|
|
|
20
20
|
- **_underscore** (_Underscore) _(framework core)_ — 22 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) —
|
|
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`). | |
|
package/knowledge/clients/prudential/features/device-information-import-and-contact-linking.md
ADDED
|
@@ -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-
|
|
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.
|
package/package.json
CHANGED