toga-ai 1.0.488 → 1.0.490
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/.claude/settings.json +20 -0
- package/knowledge/2.0/apps/_underscore/INDEX.md +3 -1
- package/knowledge/2.0/apps/_underscore/features/address-uniqueness-normalization.md +96 -0
- package/knowledge/2.0/apps/_underscore/features/address-validation.md +56 -3
- package/knowledge/2.0/apps/_underscore/features/usps-dpv-deliverability.md +103 -0
- package/knowledge/2.0/apps/api2/INDEX.md +1 -1
- package/knowledge/2.0/apps/api2/features/record-scripts.md +70 -0
- package/knowledge/2.0/apps/api2/workflows/codepipeline-codeconnections-deploy.md +35 -2
- package/knowledge/2.0/apps/dbchanges2/architecture.md +75 -2
- package/knowledge/2.0/apps/dbchanges2/features/surface-layer-schema.md +32 -0
- package/knowledge/2.0/standards/framework-rules.md +24 -0
- package/knowledge/INDEX.md +1 -1
- package/knowledge/clients/rate/INDEX.md +1 -1
- package/knowledge/clients/rate/features/whole-home-warranty-purchase-guard.md +214 -8
- package/knowledge/clients/rate/profile.md +2 -1
- package/knowledge/sessions/2026-07-30-TRUE-80487-Debug-Fix-Rate-WH-Warranty-mhammontree.md +1 -1
- package/package.json +1 -1
- package/scripts/hooks/dbchanges2-cluster-isolation.js +190 -0
- package/scripts/hooks/dbchanges2-record-ids.js +182 -0
package/.claude/settings.json
CHANGED
|
@@ -41,6 +41,26 @@
|
|
|
41
41
|
"timeout": 3000
|
|
42
42
|
}
|
|
43
43
|
]
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"matcher": "Write|Edit|MultiEdit",
|
|
47
|
+
"hooks": [
|
|
48
|
+
{
|
|
49
|
+
"type": "command",
|
|
50
|
+
"command": "node \".claude/hooks/toga/dbchanges2-cluster-isolation.js\"",
|
|
51
|
+
"timeout": 3000
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"matcher": "Write|Edit|MultiEdit",
|
|
57
|
+
"hooks": [
|
|
58
|
+
{
|
|
59
|
+
"type": "command",
|
|
60
|
+
"command": "node \".claude/hooks/toga/dbchanges2-record-ids.js\"",
|
|
61
|
+
"timeout": 3000
|
|
62
|
+
}
|
|
63
|
+
]
|
|
44
64
|
}
|
|
45
65
|
],
|
|
46
66
|
"PostToolUse": [
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
| [Proposed — git-sourced base+overlay JSON authoring for the Surface layer](architecture/surface-authoring-proposal.md) | A **proposal / handoff recommendation** (not implemented) that the Surface layer's *authoring* model move off hand-authored SQL against the `SurfaceOverrides` E | _underscore/Model/Core/Surface.php, _underscore/Model/Client/SurfaceOverride.php |
|
|
6
6
|
| [_underscore Framework Architecture](architecture.md) | `_underscore` is the shared PHP backend framework for **all 2.0 applications**. | _underscore/_underscore.php, _underscore/Loader.php, _underscore/Framework.php, _underscore/Model.php, _underscore/Database.php, _underscore/Query.php, _underscore/Route.php, _underscore/Component.php |
|
|
7
7
|
| [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, _underscore/Model/Core/Page.php, _underscore/Model/Client/TrackingNumber.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql |
|
|
8
|
-
| [Address
|
|
8
|
+
| [Address Uniqueness Normalization (unit identifier + 5-digit ZIP comparison)](features/address-uniqueness-normalization.md) | When a business rule says *"only one X per physical address"*, comparing address rows field-for-field does **not** work: the same dwelling is spelled many diffe | _underscore/Model/Rate/Entitlement.php |
|
|
9
|
+
| [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, _underscore/Component/Library/Carriers/Usps/Usps.php |
|
|
9
10
|
| [_ApiRequest — JSON encode/decode & api-logging behavior](features/apirequest-json-content-type.md) | `_ApiRequest` is the 2.0 outbound HTTP client. | _underscore/ApiRequest.php |
|
|
10
11
|
| [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 |
|
|
11
12
|
| [Asynchronous Query Execution (writes-only, via Worker)](features/async-query-execution.md) | `_Query` can run a **write** query asynchronously so a long/slow write does not hold a request-scoped DB connection open long enough to hit **"MySQL server has | _underscore/Query.php, worker2/Worker/Infrastructure/Database.php, worker2/Worker/Team/Transcripts.php |
|
|
@@ -31,5 +32,6 @@
|
|
|
31
32
|
| [Table-View Hyperlink Columns (meta → ACL → computed URL → render)](features/tableview-hyperlink-columns.md) | Any 2.0 table-view column can render its value as a clickable link instead of plain text. | _underscore/Model/Client/TableView.php, _underscore/Model/Client/TrackingNumber.php, api2/Component/Api/V2/V2.php, toga2-supply/src/api/toga.ts, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/formatTableData.tsx, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/convertData.tsx, toga2-supply/src/components/ui/Tables/hooks/useDataTableState.tsx, dbchanges2/Client/2026-07-20 - TrackingNumberHyperlinkAndFieldPermission.sql |
|
|
32
33
|
| [Tracking-Number Bridge Migration (ASN / Item Fulfillment / Item Receipt)](features/tracking-number-bridges.md) | Shipment tracking numbers used to live as **scalar FK columns** (`trackingNumberId`, `returnTrackingNumberId`) directly on the lowest-level "unit"/"item" tables | api2/Component/Api/V2/V2.php, _underscore/Model/Client/AdvanceShippingNoticeItemUnit.php, _underscore/Model/Client/AdvanceShippingNoticeItemUnits/TrackingNumber.php, _underscore/Model/Client/ItemFulfillmentItemUnits/TrackingNumber.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Prudential/AdvanceShippingNotice.php, _underscore/Model/Compass/AdvanceShippingNotice.php, _underscore/Trait/Netsuite/ItemFulfillment.php, api2/Component/Api/Cxml/Cxml.php, dbchanges2/Client/2026-06-10 - TrackingNumberBridges.sql, dbchanges2/Core/2026-06-10 - TrackingNumberBridges.sql, dbchanges2/Client_Prudential/2026-06-15 - ItemFulfillmentTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Quad/2026-06-18a - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Nychh/2026-06-19b - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Growrk/2026-07-13a - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Nychh/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql, dbchanges2/Client_Quad/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql |
|
|
33
34
|
| [Units for Items for Purchase Orders — Data Structure](features/units-for-items-for-purchase-orders.md) | Describes how unit (serialized inventory) data is linked to sales-order and purchase-order line items behind the `units-for-items-for-purchase-orders` TableView | |
|
|
35
|
+
| [USPS DPV Deliverability Verdict (is this address actually insurable/shippable?)](features/usps-dpv-deliverability.md) | **USPS returning HTTP 200 with a populated address is NOT evidence that the address is deliverable.** The authoritative signal is USPS's **DPV (Delivery Point V | _underscore/Component/Library/Carriers/Usps/Usps.php, _underscore/Model/Client/Address.php, _underscore/Model/Rate/Entitlement.php |
|
|
34
36
|
| [Refreshing a Local Dev Database from Beta (dev-sandbox)](workflows/local-db-refresh-from-beta.md) | How to reset a local 2.0 dev database from the **beta / dev-sandbox** environment: dump each schema (`Core`, `Client_<Id>`, `Logs_<Id>`, …) from the beta host, | api2/Config/, _underscore/Loader.php, _underscore/Model/Client/BundleTranslation.php, api2/Component/Api/V2/V2.php, toga25-supply/sync_compasscanada_schema.sql |
|
|
35
37
|
| [Running a 2.0 App Locally (browser, end-to-end via api2)](workflows/running-a-2.0-app-locally.md) | The full dependency chain required to run a 2.0 client app **through the browser**, end-to-end, against a **local `api2`** (e.g. | _underscore/Environment.php, _underscore/Config.php, _underscore/Database.php, _underscore/Route.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php, api2/index.php, api2/.htaccess |
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Address Uniqueness Normalization (unit identifier + 5-digit ZIP comparison)"
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: _underscore
|
|
5
|
+
project: _Underscore
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-07-31
|
|
10
|
+
owners: [mhammontree]
|
|
11
|
+
files:
|
|
12
|
+
- _underscore/Model/Rate/Entitlement.php
|
|
13
|
+
related:
|
|
14
|
+
- ./address-validation.md
|
|
15
|
+
- ./usps-dpv-deliverability.md
|
|
16
|
+
- ../../../clients/rate/features/whole-home-warranty-purchase-guard.md
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Summary
|
|
20
|
+
|
|
21
|
+
When a business rule says *"only one X per physical address"*, comparing address rows
|
|
22
|
+
field-for-field does **not** work: the same dwelling is spelled many different ways
|
|
23
|
+
(`Suite 500` / `STE 500` / `Ste. 500` / `#500`), and carrier validation returns a **ZIP+4**
|
|
24
|
+
while every legacy row holds 5 digits. This doc records the two normalization primitives
|
|
25
|
+
built for the Rate Whole Home Warranty per-address guard, both of which are **reusable for any
|
|
26
|
+
"one per address" rule** on the platform.
|
|
27
|
+
|
|
28
|
+
Both live as private helpers on `_Model_Rate_Entitlement` today (first use); promote them to a
|
|
29
|
+
shared helper when a second consumer appears.
|
|
30
|
+
|
|
31
|
+
## `normalizeUnitIdentifier()` — compare the UNIT, discard the designator
|
|
32
|
+
|
|
33
|
+
Line 2 is compared by **unit identifier only**. Everything that is not `A–Z0–9` is stripped,
|
|
34
|
+
the result upper-cased, and any leading designator word removed:
|
|
35
|
+
|
|
36
|
+
| Input `line2` | Normalized unit |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `Suite 500`, `STE 500`, `Ste. 500`, `#500`, `Apt 500`, `No. 500`, `500` | `500` |
|
|
39
|
+
| `4B`, `12A`, `PH2`, `101-A` | `4B`, `12A`, `PH2`, `101A` |
|
|
40
|
+
| `""`, `NULL`, invisible-only (see below) | *(no unit)* |
|
|
41
|
+
|
|
42
|
+
**Why discard the designator instead of maintaining a synonym list.** Within a single
|
|
43
|
+
`line1 + city + ZIP5`, the designator carries **no distinguishing information** — there is no
|
|
44
|
+
real building with both a "Suite 500" and an "Apt 500". Discarding it removes any synonym
|
|
45
|
+
table to maintain and errs toward **MATCHING**, which is the correct bias for a *uniqueness
|
|
46
|
+
guard* (a false match blocks a purchase and a human resolves it; a false non-match silently
|
|
47
|
+
creates the duplicate the rule exists to prevent).
|
|
48
|
+
|
|
49
|
+
**An EMPTY unit means "the whole building"** and therefore conflicts with **ANY** unit at the
|
|
50
|
+
same street address, **in both directions**. So a building-wide warranty and a per-unit
|
|
51
|
+
warranty can never coexist.
|
|
52
|
+
|
|
53
|
+
**`trim()` alone is insufficient.** The `[^A-Z0-9]` strip is what collapses the invisible
|
|
54
|
+
characters that survive `trim()` — NBSP `U+00A0`, ZWSP `U+200B`, ZWJ `U+200D`, BOM `U+FEFF`,
|
|
55
|
+
ideographic space `U+3000`. An invisible-only `line2` normalizes to *no unit*, identical to
|
|
56
|
+
`NULL`, which is the intent.
|
|
57
|
+
|
|
58
|
+
## `comparableZip()` — compare the first 5 digits only
|
|
59
|
+
|
|
60
|
+
USPS returns **ZIP+4** (verified live: `60503-7832`) while every pre-existing `Addresses` row
|
|
61
|
+
holds 5 digits. An exact ZIP comparison therefore missed **every legacy row**. Uniqueness
|
|
62
|
+
compares `substr(digits, 0, 5)`.
|
|
63
|
+
|
|
64
|
+
## Where the comparison happens: SQL narrows, PHP decides
|
|
65
|
+
|
|
66
|
+
The dedup query narrows candidates on `line1 + city + 5-digit ZIP + state`, and the **unit
|
|
67
|
+
comparison runs in PHP** over the returned candidates.
|
|
68
|
+
|
|
69
|
+
**Why not do it in SQL.** Real normalization cannot be expressed in SQL without a nested
|
|
70
|
+
`REPLACE()` chain, which is both unreadable and **unindexable** (no index can serve it, so it
|
|
71
|
+
degrades to a full scan). Narrow in SQL on indexable columns, then compare in PHP.
|
|
72
|
+
|
|
73
|
+
The candidate fetch is capped at **`DEDUP_CANDIDATE_LIMIT = 500`** with an `error_log` canary
|
|
74
|
+
if the cap is ever hit — a single street address with 500+ address rows means something else
|
|
75
|
+
is wrong.
|
|
76
|
+
|
|
77
|
+
## Gotchas / known issues
|
|
78
|
+
|
|
79
|
+
- **Never judge address duplication from a query that omits `line2`.** During this session
|
|
80
|
+
three beta entitlements looked like duplicates on a `line1 + zip` projection and were in
|
|
81
|
+
fact **distinct units** (`STE 1900` / `# 4` / `STE 500`). Always select `line2` before
|
|
82
|
+
concluding two addresses are the same — and certainly before deleting anything.
|
|
83
|
+
- **Bias is deliberately toward matching.** If a legitimate distinct unit is ever blocked, fix
|
|
84
|
+
it by making the *guard's* message actionable, not by adding designator sensitivity back.
|
|
85
|
+
- **Promote before the second copy.** These helpers are Rate-private only because Rate is the
|
|
86
|
+
first consumer. Copy-pasting them into a second client model is how normalization rules
|
|
87
|
+
drift apart.
|
|
88
|
+
|
|
89
|
+
## Change history
|
|
90
|
+
- 2026-07-31 — Built `normalizeUnitIdentifier()` (designator-discarding unit comparison,
|
|
91
|
+
invisible-character collapse, empty-unit-conflicts-with-everything semantics) and
|
|
92
|
+
`comparableZip()` (5-digit comparison, because USPS returns ZIP+4 and legacy rows hold 5)
|
|
93
|
+
for the Rate WH per-address guard (TRUE-80487). Recorded the SQL-narrows/PHP-decides split
|
|
94
|
+
(real normalization is unindexable in SQL), `DEDUP_CANDIDATE_LIMIT = 500` + canary, and the
|
|
95
|
+
"never judge duplication without `line2`" lesson from three beta rows misread as duplicates.
|
|
96
|
+
(mhammontree)
|
|
@@ -6,13 +6,16 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
9
|
+
updated: 2026-07-31
|
|
10
10
|
owners: [mhammontree]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Client/Address.php
|
|
13
|
+
- _underscore/Component/Library/Carriers/Usps/Usps.php
|
|
13
14
|
related:
|
|
14
15
|
- ../../api2/features/scripted-api-post-body-args.md
|
|
15
16
|
- ../../api2/architecture.md
|
|
17
|
+
- ./usps-dpv-deliverability.md
|
|
18
|
+
- ./address-uniqueness-normalization.md
|
|
16
19
|
- ../../../clients/rate/features/whole-home-warranty-purchase-guard.md
|
|
17
20
|
---
|
|
18
21
|
|
|
@@ -34,6 +37,16 @@ Whole Home Warranty purchase guard).
|
|
|
34
37
|
`zip4`/`zip5`.
|
|
35
38
|
- **On failure** (all carriers fail) returns the **last (UPS) response** with
|
|
36
39
|
`success:false` (and an `error`).
|
|
40
|
+
- **Since 2026-07-31** the USPS leg also returns `DPVConfirmation`, `vacant`, `business` and
|
|
41
|
+
`corrections[]`, and `_Model_Client_Address` exposes `DPV_*` constants +
|
|
42
|
+
`withValidationDefaults()` — see
|
|
43
|
+
[USPS DPV deliverability](./usps-dpv-deliverability.md).
|
|
44
|
+
|
|
45
|
+
> **`success:true` does NOT mean the address is deliverable.** USPS returns HTTP 200 with a
|
|
46
|
+
> populated address even when the secondary unit is **missing or invented**. Any caller whose
|
|
47
|
+
> business rule depends on the address being real (insurance, warranty, shipment to a unit)
|
|
48
|
+
> must read the **DPV verdict**, not just `success`. See
|
|
49
|
+
> [USPS DPV deliverability](./usps-dpv-deliverability.md).
|
|
37
50
|
|
|
38
51
|
## The scripted endpoint is registered in the DB, not in source (parity gap)
|
|
39
52
|
|
|
@@ -68,8 +81,19 @@ in `dbchanges2` source** (closing the parity gap noted below) rather than only i
|
|
|
68
81
|
distinct from the older global `validateAddress` route's `data.addresses.validateAddress`).
|
|
69
82
|
|
|
70
83
|
The PHP method (`_Model_Client_Address::validateAddress`) already existed and was unchanged — it was
|
|
71
|
-
simply **unrouted in source** until this migration set exposed it.
|
|
72
|
-
|
|
84
|
+
simply **unrouted in source** until this migration set exposed it.
|
|
85
|
+
|
|
86
|
+
> **⚠ CORRECTION (2026-07-31): that grant migration never took effect.**
|
|
87
|
+
> `Client/2026-07-16c - AddressValidateScriptAcl.sql` **silently no-ops** — Core script **28**
|
|
88
|
+
> (`addresses/validate`) has **ZERO** `AclRecordScripts` rows in `Client_Rate`. The file resolves
|
|
89
|
+
> the Core script id with a cross-database `Core.*` read from a fan-out `Client/` folder, which
|
|
90
|
+
> cannot work: in production `Core` is a **separate cluster**, and on a developer machine the
|
|
91
|
+
> literal schema `Core` is the **1.0** database (the 2.0 core is `Core_2`) which has no
|
|
92
|
+
> `RecordScripts` table at all. Either way the `SET @var` stays NULL and the `NOT EXISTS`-guarded
|
|
93
|
+
> INSERT inserts nothing. **Do not copy this file as the pattern.** For a *client-specific*
|
|
94
|
+
> scripted API, register it as a **client-side `CustomRecordScripts`** instead — registration and
|
|
95
|
+
> grant then live in one database (see
|
|
96
|
+
> [Record Scripts → client-side CustomRecordScripts](../../api2/features/record-scripts.md)).
|
|
73
97
|
|
|
74
98
|
## Client variations
|
|
75
99
|
|
|
@@ -83,8 +107,37 @@ per the standard client carrier config.
|
|
|
83
107
|
migrations into a fresh DB. Register scripted endpoints in `dbchanges2` going forward.
|
|
84
108
|
- **Waterfall requires agreement on success.** The success shape is only returned when the
|
|
85
109
|
carriers agree; a `success:false` result carries the last (UPS) response, not a merged one.
|
|
110
|
+
- **Scripted-API parity gaps (audited 2026-07-31).** Source and live DB disagree in both
|
|
111
|
+
directions — verify against the live `Core.RecordScripts` rows, never source alone:
|
|
112
|
+
- **`addresses/validateAddress` (id 17), the endpoint `toga2-view` actually calls, exists ONLY
|
|
113
|
+
as a live DB row** — there is **no `dbchanges2` migration for it**. A freshly provisioned
|
|
114
|
+
environment would **404** on the endpoint the frontend depends on.
|
|
115
|
+
- **`tracking-numbers/reprint` is MISSING ENTIRELY from production `Core.RecordScripts`** — so
|
|
116
|
+
`Core/2026-07-01a - TrackingNumberReprintRecordScript.sql` never took effect in production.
|
|
117
|
+
- **`addresses/validate` (id 28) has zero `AclRecordScripts` grants in `Client_Rate`** (see the
|
|
118
|
+
correction above).
|
|
119
|
+
- **`Core.RecordScripts` ids are NOT stable across environments** — max id is 27 in production,
|
|
120
|
+
28 local, 28 client-sandbox, 30 dev-sandbox, and the *same* script has different ids per
|
|
121
|
+
environment. Never hardcode a `RecordScripts` id, and never assume "the next available id"
|
|
122
|
+
means the same thing anywhere else. (`Core.Records` ids, by contrast, **are** seeded
|
|
123
|
+
identically everywhere — Addresses = 13.)
|
|
124
|
+
- **USPS OAuth credentials are committed in plaintext** in
|
|
125
|
+
`_underscore/Component/Library/Carriers/Usps/Usps.php` (constants near the top of the file);
|
|
126
|
+
they belong in the `_Config` INI. Rotate and relocate — see the
|
|
127
|
+
[DPV doc](./usps-dpv-deliverability.md). *(Location only; no value recorded.)*
|
|
86
128
|
|
|
87
129
|
## Change history
|
|
130
|
+
- 2026-07-31 — TRUE-80487: recorded that **`success:true` does not mean deliverable** (USPS
|
|
131
|
+
returns 200 for a missing/invented unit) and split the DPV verdict out into
|
|
132
|
+
[usps-dpv-deliverability.md](./usps-dpv-deliverability.md); the USPS leg now also returns
|
|
133
|
+
`DPVConfirmation`/`vacant`/`business`/`corrections`. **Corrected the 2026-07-17 entry below:**
|
|
134
|
+
`Client/2026-07-16c - AddressValidateScriptAcl.sql` **silently no-ops** (script 28 has zero
|
|
135
|
+
`AclRecordScripts` rows in `Client_Rate`) because it reads `Core.*` from a fan-out `Client/`
|
|
136
|
+
folder — unreachable in prod (separate cluster) *and* locally (`Core` is the **1.0** DB; the 2.0
|
|
137
|
+
core is `Core_2`). It is **not** the pattern to copy. Added the audited scripted-API parity gaps
|
|
138
|
+
(script 17 exists only in the live DB with no migration; `tracking-numbers/reprint` absent from
|
|
139
|
+
production) and the `Core.RecordScripts` id-drift fact. Also recorded the committed plaintext
|
|
140
|
+
USPS credential location. (mhammontree)
|
|
88
141
|
|
|
89
142
|
- 2026-07-17 — TRUE-79191: added a **`dbchanges2`-registered** `GET /addresses/validate` route to the
|
|
90
143
|
same `validateAddress` method (Core `RecordScripts` route `validate` + Client `AclRecordScripts`
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "USPS DPV Deliverability Verdict (is this address actually insurable/shippable?)"
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: _underscore
|
|
5
|
+
project: _Underscore
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-07-31
|
|
10
|
+
owners: [mhammontree]
|
|
11
|
+
files:
|
|
12
|
+
- _underscore/Component/Library/Carriers/Usps/Usps.php
|
|
13
|
+
- _underscore/Model/Client/Address.php
|
|
14
|
+
- _underscore/Model/Rate/Entitlement.php
|
|
15
|
+
related:
|
|
16
|
+
- ./address-validation.md
|
|
17
|
+
- ./address-uniqueness-normalization.md
|
|
18
|
+
- ../../../clients/rate/features/whole-home-warranty-purchase-guard.md
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Summary
|
|
22
|
+
|
|
23
|
+
**USPS returning HTTP 200 with a populated address is NOT evidence that the address is
|
|
24
|
+
deliverable.** The authoritative signal is USPS's **DPV (Delivery Point Validation)**
|
|
25
|
+
confirmation code, which the carrier waterfall previously **discarded**. This doc records what
|
|
26
|
+
DPV means, the live-verified verdicts, and the platform mapping now applied.
|
|
27
|
+
|
|
28
|
+
USPS will happily echo back an **invented** secondary unit and still return 200 — so adopting
|
|
29
|
+
the "recommended" address alone does **not** fix a bad unit. **The DPV field must be read.**
|
|
30
|
+
|
|
31
|
+
## The verdicts (VERIFIED against live USPS responses 2026-07-31)
|
|
32
|
+
|
|
33
|
+
| Address probed | `DPVConfirmation` | Meaning |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `1904 IONE LN, AURORA IL 60503` (no unit) | **`Y`** | deliverable exactly as given |
|
|
36
|
+
| `1904 IONE LN` + `UNIT 200` (invented; single-family) | **`S`** | secondary present but **not confirmed** |
|
|
37
|
+
| `2511 VERSAILLES AVE, NAPERVILLE IL 60540` (no unit) | **`D`** | secondary **MISSING** — building is subdivided |
|
|
38
|
+
| `2511 VERSAILLES AVE` + `# 1` (invalid) | **`S`** | secondary not confirmed |
|
|
39
|
+
|
|
40
|
+
The `D` case additionally returns `corrections[].code` **`32`** — *"Default address: … more
|
|
41
|
+
information is needed (such as an apartment, suite, or box number)."*
|
|
42
|
+
|
|
43
|
+
**Platform mapping:**
|
|
44
|
+
|
|
45
|
+
| DPV | Action |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `Y` | proceed |
|
|
48
|
+
| `D` | reject — **unit required** |
|
|
49
|
+
| `S` | reject — **unit not verified** |
|
|
50
|
+
| `N` | reject |
|
|
51
|
+
| `''` / absent | **unknown → PROCEED** (see decision below) |
|
|
52
|
+
|
|
53
|
+
## DECIDED: unknown DPV proceeds (fails OPEN, deliberately)
|
|
54
|
+
|
|
55
|
+
FedEx and UPS — the waterfall's fallbacks — **do not report DPV at all**. If a fallback carrier
|
|
56
|
+
answered, the DPV verdict is unknown. That case **proceeds**: failing closed would block
|
|
57
|
+
**every** purchase during a USPS outage, and for the uniqueness use case the
|
|
58
|
+
[dedup guard](./address-uniqueness-normalization.md) still prevents duplicates independently.
|
|
59
|
+
This is a conscious availability-over-strictness trade; do not "harden" it without a
|
|
60
|
+
business-owner decision.
|
|
61
|
+
|
|
62
|
+
## What was captured
|
|
63
|
+
|
|
64
|
+
- **`Usps.php`** now captures `DPVConfirmation`, `vacant`, `business`, and `corrections[]` off
|
|
65
|
+
the USPS response. `vacant` and `business` are **free** in `additionalInfo` and are now
|
|
66
|
+
carried through as potential underwriting inputs (no rule consumes them yet).
|
|
67
|
+
- **`_Model_Client_Address`** gained `DPV_*` constants plus **`withValidationDefaults()`**,
|
|
68
|
+
which is **purely additive**: it is implemented as `$response + [defaults]`, so the **left
|
|
69
|
+
operand wins** and a real carrier verdict can never be clobbered by a default.
|
|
70
|
+
|
|
71
|
+
## Carriers cannot tell you DWELLING TYPE
|
|
72
|
+
|
|
73
|
+
A recurring ask ("is this an apartment building?") is **not answerable from carrier data**:
|
|
74
|
+
|
|
75
|
+
- UPS `AddressClassification` and FedEx classification only distinguish **Residential vs
|
|
76
|
+
Commercial** — an apartment building is *Residential*.
|
|
77
|
+
- The only actionable signal available is USPS DPV **"secondary unit required"** (`D`).
|
|
78
|
+
- **True dwelling type requires property data** — an assessor/parcel feed or a
|
|
79
|
+
Melissa/Smarty-class vendor. That is a **procurement decision, not a code one.**
|
|
80
|
+
|
|
81
|
+
## Gotchas / known issues
|
|
82
|
+
|
|
83
|
+
- **HTTP 200 ≠ deliverable.** This is the whole point of the doc. Success + a populated
|
|
84
|
+
address is the *normal* response for a missing or invented unit.
|
|
85
|
+
- **USPS does not strip an invented unit.** It echoes `UNIT 200` back verbatim. Adopting the
|
|
86
|
+
recommended address does not sanitize it.
|
|
87
|
+
- **Plaintext USPS OAuth credentials are committed in source** — the client id and secret are
|
|
88
|
+
hardcoded as constants near the top of
|
|
89
|
+
`_underscore/Component/Library/Carriers/Usps/Usps.php`. They belong in the `_Config` INI
|
|
90
|
+
alongside the DB credentials. **Treat them as compromised: rotate and move to SSM / EB
|
|
91
|
+
environment properties.** Pre-existing, not introduced by this work. *(Location +
|
|
92
|
+
remediation only — no value is recorded anywhere.)*
|
|
93
|
+
|
|
94
|
+
## Change history
|
|
95
|
+
- 2026-07-31 — Discovered that the carrier waterfall was **discarding** USPS DPV, and built
|
|
96
|
+
capture + enforcement (TRUE-80487): `Usps.php` now returns `DPVConfirmation`/`vacant`/
|
|
97
|
+
`business`/`corrections`, `_Model_Client_Address` gained `DPV_*` constants and the additive
|
|
98
|
+
`withValidationDefaults()`, and Rate's WH guard enforces `Y` proceed / `D` unit-required /
|
|
99
|
+
`S` unit-unverified / `N` reject / unknown proceed. Verdicts verified against four live USPS
|
|
100
|
+
probes; recorded that USPS echoes an invented unit back with a 200, that unknown DPV fails
|
|
101
|
+
**open** by decision, and that carriers cannot supply dwelling type (only Residential vs
|
|
102
|
+
Commercial) so an apartment-vs-house rule needs property data. Also recorded the committed
|
|
103
|
+
plaintext USPS OAuth credential location for rotation. (mhammontree)
|
|
@@ -20,5 +20,5 @@
|
|
|
20
20
|
| [V2 API error/message codes (EV/EZ troubleshooting map)](features/v2-api-error-codes.md) | The V2 JSON engine (`Component/Api/V2/V2.php`) returns short **message codes** in the response `error` field, grouped by family: `EN-*` authentication, `EZ-*` a | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, api2/Component/Api/V2/Response/Oauth/Oauth.php, api2/Controller/Index.php, toga2-supply/src/globalTypes.ts, toga2-supply/src/pages/Orders/api/OrdersApi.ts, _underscore/Model/Client/TrackingNumber.php |
|
|
21
21
|
| [V2 REST query contract (params, where grammar, encoding, ACL behavior)](features/v2-rest-query-contract.md) | What an **HTTP client** has to get right to query the Toga v2 REST API: which query params are recognized, the exact `where` grammar, how the query string is (n | api2/Component/Api/V2/V2.php |
|
|
22
22
|
| [V2 reverse hasMany collections must be named in the fetch fields whitelist](features/v2-reverse-hasmany-fields-whitelist.md) | In the V2 JSON engine, a **reverse hasMany** relationship — the collection of child records that foreign-key back to a parent (e.g. | api2/Component/Api/V2/V2.php |
|
|
23
|
-
| [AWS CodePipeline Deployment via CodeConnections (GitHub → Elastic Beanstalk)](workflows/codepipeline-codeconnections-deploy.md) | 2.0 apps (`api2`, `_underscore`) are deployed through **AWS CodePipeline**. | api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, api2/ebs/register_instance_to_shared_application_load_balancer.php |
|
|
23
|
+
| [AWS CodePipeline Deployment via CodeConnections (GitHub → Elastic Beanstalk)](workflows/codepipeline-codeconnections-deploy.md) | 2.0 apps (`api2`, `_underscore`) are deployed through **AWS CodePipeline**. | api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, api2/ebs/register_instance_to_shared_application_load_balancer.php, api2/.platform/hooks/prebuild/git.sh, api2/.ebextensions/git.php, api2/.ebextensions/git.sandbox-dev.json |
|
|
24
24
|
| [New Environment Configuration & Provisioning (api2)](workflows/environment-configuration-and-provisioning.md) | What it takes for a 2.0 API environment (e.g. | api2/Config/<environment>.ini, api2/Controller/Index.php, dbchanges2/Core/2026-06-16a - DatabaseHosts for new QA QC stage demo environments.sql, dbchanges2/Logs/, _underscore/Route.php |
|
|
@@ -136,6 +136,56 @@ metadata model.
|
|
|
136
136
|
endpoint goes in `Client_True`. The generic **`Client/`** folder runs against **every**
|
|
137
137
|
tenant DB, so putting an ACL grant there hands the endpoint to all clients.
|
|
138
138
|
|
|
139
|
+
> **⚠ The "resolve the Core id by route from the client file" convention above is NOT runnable
|
|
140
|
+
> for the `AclRecordScripts` grant.** `AclRecordScripts.recordScriptId` links to
|
|
141
|
+
> `Core.RecordScripts` by **NUMERIC id** (there is no uuid reference column), so a `Client_*`
|
|
142
|
+
> migration must read `Core` to resolve it — which is impossible in production (separate
|
|
143
|
+
> clusters) **and equally broken in local dev**: locally the 2.0 core schema is **`Core_2`**, and
|
|
144
|
+
> the schema literally named `Core` is the **1.0** database, which has no `RecordScripts` and no
|
|
145
|
+
> `RecordFields`. So a literal `Core.<Table>` reference resolves to the wrong (or missing) schema
|
|
146
|
+
> in *both* environments, and a `NOT EXISTS`-guarded INSERT inserts nothing without erroring.
|
|
147
|
+
> **A clean local run is therefore never proof that such a migration worked.** This is not
|
|
148
|
+
> theoretical: `Client/2026-07-16c - AddressValidateScriptAcl.sql` **silently no-ops**, leaving
|
|
149
|
+
> Core script 28 with zero grants in `Client_Rate`. For a **client-specific** script, use the CustomRecordScripts pattern below.
|
|
150
|
+
|
|
151
|
+
## ✅ REUSABLE PATTERN — register a CLIENT-SPECIFIC scripted API as a `CustomRecordScript`
|
|
152
|
+
|
|
153
|
+
**When a scripted API is declared on a client model (`_Model_<Slug>_X`), register it in the
|
|
154
|
+
CLIENT database as a `CustomRecordScript` — NOT in `Core.RecordScripts`.** First applied in
|
|
155
|
+
`dbchanges2/Client_Rate/2026-07-31a - WarrantyAvailabilityCustomRecordScript.sql`
|
|
156
|
+
(`_Model_Rate_Entitlement::warrantyAvailability`).
|
|
157
|
+
|
|
158
|
+
**Why (three independent reasons):**
|
|
159
|
+
|
|
160
|
+
1. **One database, so the grant is resolvable.** `CustomRecordScripts` **and**
|
|
161
|
+
`AclCustomRecordScripts` are **both** `DB_CLIENT`
|
|
162
|
+
(`_Model_Client_CustomRecordScript` / `_Model_Client_AclCustomRecordScript`). Registration and
|
|
163
|
+
grant therefore live in the **same** database, and the grant can resolve the script id from a
|
|
164
|
+
**hardcoded v4 UUID literal** via a same-database lookup — **no id needs pinning and no
|
|
165
|
+
cross-team coordination is required.** A `Core.RecordScripts` registration puts the id in
|
|
166
|
+
`Core` while the grant lives in the client DB and links by numeric id, forcing exactly the
|
|
167
|
+
impossible cross-tenant read described above.
|
|
168
|
+
2. **api2 reads both from `DB_CLIENT`.** In `api2/Component/Api/V2/V2.php`, the
|
|
169
|
+
`lookupCustomRecordScriptsByRecordIdAndMethodAndRoute` map is built from
|
|
170
|
+
`SELECT … FROM CustomRecordScripts` (~line 8613) and authorization goes through
|
|
171
|
+
`AclCustomRecordScripts` (~line 5919). **`Core.RecordScripts` are checked FIRST, with a
|
|
172
|
+
fallback to custom** (~line 5906) — so a custom script cannot shadow a global one, and a
|
|
173
|
+
global one always wins.
|
|
174
|
+
3. **It is semantically correct.** The method is declared on the *client* model, not the base
|
|
175
|
+
model. Registering it in `Core` would advertise the route to **every** tenant, including
|
|
176
|
+
tenants whose model has no such method.
|
|
177
|
+
|
|
178
|
+
**Authoring notes from the first use:**
|
|
179
|
+
- `recordId` is written as the **literal** (191 for Entitlements) per team convention —
|
|
180
|
+
`Core.Records` ids **are** deliberately seeded identically in every environment
|
|
181
|
+
(Addresses 13, Sales orders 14, Entitlements 191, Subscriptions 253). This is safe;
|
|
182
|
+
`Core.RecordScripts` ids are **not** (they have drifted per environment).
|
|
183
|
+
- **Grant `Base` only, resolved by NAME** — role ids differ per client.
|
|
184
|
+
- **Do NOT grant `Public`.** A warranty-availability endpoint reveals whether a warranty exists
|
|
185
|
+
at an arbitrary address; unauthenticated probing must be prevented.
|
|
186
|
+
- Verified by applying the file **twice** to two environments: exactly one script row and one
|
|
187
|
+
grant row each.
|
|
188
|
+
|
|
139
189
|
## Data source note (core DB hosts/credentials)
|
|
140
190
|
|
|
141
191
|
The core cluster hosts and credentials this endpoint reads through are **not** documented here;
|
|
@@ -149,6 +199,12 @@ an `AclRecordScripts` grant in each client DB.
|
|
|
149
199
|
|
|
150
200
|
## Gotchas / known issues
|
|
151
201
|
|
|
202
|
+
- **The response key is the ROUTE VERBATIM, not the `phpMethod`.** A script registered with
|
|
203
|
+
route `warranty-availability` and `phpMethod` `warrantyAvailability` returns
|
|
204
|
+
**`data.entitlements["warranty-availability"]`** — *not* `data.entitlements.warrantyAvailability`.
|
|
205
|
+
The sibling `addresses/validateAddress` reader looks camelCase only because **that script's
|
|
206
|
+
route is itself `validateAddress`.** Reading the wrong key yields `undefined` (and, in a
|
|
207
|
+
fail-closed consumer, correctly denies) — this cost a debug cycle in beta.
|
|
152
208
|
- **No bound params in `_Query`.** Sanitize with `\_Database::escape()` (strings) and `(int)`
|
|
153
209
|
casts (numerics) for every value placed into the query text — query-string args are user
|
|
154
210
|
input.
|
|
@@ -169,6 +225,20 @@ an `AclRecordScripts` grant in each client DB.
|
|
|
169
225
|
|
|
170
226
|
## Change history
|
|
171
227
|
|
|
228
|
+
- 2026-07-31 — TRUE-80487: added the **reusable client-side `CustomRecordScript` pattern** for a
|
|
229
|
+
scripted API declared on a client model — `CustomRecordScripts` + `AclCustomRecordScripts` are
|
|
230
|
+
**both** `DB_CLIENT`, so registration and grant land in **one** database and the grant resolves
|
|
231
|
+
the script id from a hardcoded uuid (no id pinning, no cross-team coordination); api2 reads both
|
|
232
|
+
from `DB_CLIENT` (`V2.php` ~8613 / ~5919) and checks `Core.RecordScripts` **first** with a
|
|
233
|
+
fallback to custom (~5906). Recorded **why the existing `Core.RecordScripts` + `Client_*`
|
|
234
|
+
`AclRecordScripts` convention is unrunnable** for a client-scoped script
|
|
235
|
+
(`AclRecordScripts.recordScriptId` links by numeric id with no uuid reference → forced
|
|
236
|
+
cross-cluster read; `Client/2026-07-16c - AddressValidateScriptAcl.sql` silently no-ops, script
|
|
237
|
+
28 has zero grants in `Client_Rate`). Also recorded that the response key is the **route
|
|
238
|
+
verbatim** (`data.entitlements["warranty-availability"]`, not the camelCase `phpMethod`), that
|
|
239
|
+
`Core.Records` ids are environment-stable literals while `Core.RecordScripts` ids have drifted,
|
|
240
|
+
and the grant-Base-by-name / never-Public guidance. First use:
|
|
241
|
+
`dbchanges2/Client_Rate/2026-07-31a - WarrantyAvailabilityCustomRecordScript.sql`. (mhammontree)
|
|
172
242
|
- 2026-07-24 — Refined the engine contract from productionizing the sprint dashboard (verified
|
|
173
243
|
in `V2.php` + `_underscore/Query.php`): the return value **nests under the route-segment key**
|
|
174
244
|
(`data[<route>]`, not flat — consumers must unwrap); there is **no current-sprint/context
|
|
@@ -6,12 +6,16 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: workflow
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
9
|
+
updated: 2026-07-31
|
|
10
10
|
owners: ["jcardinal", "mhammontree"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh
|
|
13
13
|
- api2/ebs/register_instance_to_shared_application_load_balancer.php
|
|
14
|
-
|
|
14
|
+
- api2/.platform/hooks/prebuild/git.sh
|
|
15
|
+
- api2/.ebextensions/git.php
|
|
16
|
+
- api2/.ebextensions/git.sandbox-dev.json
|
|
17
|
+
related:
|
|
18
|
+
- ../../../standards/frontend-deploy.md
|
|
15
19
|
---
|
|
16
20
|
|
|
17
21
|
## Summary
|
|
@@ -40,6 +44,27 @@ app-repo `_<env>` branches.
|
|
|
40
44
|
> this env; you must push it to **`_sandbox-dev`**. Always confirm the exact env↔branch mapping in the
|
|
41
45
|
> env's `git.<env>.json` before assuming `_<env>` applies.
|
|
42
46
|
|
|
47
|
+
### "beta" is a DIFFERENT git branch per repo — the full map (verified 2026-07-31)
|
|
48
|
+
|
|
49
|
+
The word *beta* names an **environment**, not a branch, and each tier resolves it differently.
|
|
50
|
+
Getting this wrong cost **two wasted deploys** in one session (TRUE-80487). Confirm the tier you
|
|
51
|
+
are actually changing before you merge anything:
|
|
52
|
+
|
|
53
|
+
| Tier | What "beta" is | Branch actually built | Notes |
|
|
54
|
+
|---|---|---|---|
|
|
55
|
+
| `api2` / `_underscore` | EB env **`API-Sandbox-Dev`** behind `api.beta.togahub.com` | `_underscore` **`_sandbox-dev`** (per `.ebextensions/git.sandbox-dev.json`) | **Not `_beta`.** Database environment is **`dev-sandbox`**. |
|
|
56
|
+
| `toga2-view` | AWS Amplify target **named "production"** | git branch **`_beta`**, bundling `.env.beta` (`VITE_API=https://api.beta.togahub.com/v2`) | The target *name* is misleading; proven by a string present only in `_beta` appearing in the deployed UI and absent from `_production`. |
|
|
57
|
+
|
|
58
|
+
- **`Client_Rate` exists in `prod`, `dev-sandbox` and `client-sandbox` — NOT in `client-beta`.** So
|
|
59
|
+
"the beta database" is ambiguous too; name the concrete DB environment.
|
|
60
|
+
- **`_underscore` is NOT vendored into `api2`.** `.platform/hooks/prebuild/git.sh` +
|
|
61
|
+
`.ebextensions/git.php` clone it at build time (branch = the per-env json's `branch`, else
|
|
62
|
+
`_<environment>`). Consequence: **a framework-only change requires REDEPLOYING THE CONSUMING
|
|
63
|
+
TIER even when `api2` itself has zero changes** — an instance **restart will not pick it up**,
|
|
64
|
+
because the clone happens in prebuild.
|
|
65
|
+
- **`api2` has no deploy workflow.** Its only GitHub Action is the `TRUE-#####:` commit-message
|
|
66
|
+
linter, so **EB deploys are manual**. Do not wait for CI to ship a framework change.
|
|
67
|
+
|
|
43
68
|
### Debugging a beta env without Sentry — `Logs_<Client>.Api`
|
|
44
69
|
`Logs_<Client>.Api` is a **full server-side request log**: `queryString`, request/response payloads,
|
|
45
70
|
`direction` (`IN`/`OUT`), `instanceId`, and `executionTime`. It is the reliable way to see what a
|
|
@@ -139,6 +164,14 @@ aws codeconnections get-connection --connection-arn "<CONN_ARN>" --region "$REGI
|
|
|
139
164
|
```
|
|
140
165
|
|
|
141
166
|
## Change history
|
|
167
|
+
- 2026-07-31 — TRUE-80487: added the **full cross-tier "beta" environment map** after it cost two
|
|
168
|
+
wasted deploys. `api.beta.togahub.com` is EB env **`API-Sandbox-Dev`** on the **`dev-sandbox`**
|
|
169
|
+
database, cloning `_underscore` **`_sandbox-dev`**; `toga2-view`'s Amplify target **named
|
|
170
|
+
"production"** actually builds branch **`_beta`** with `.env.beta` pointing at that API. Recorded
|
|
171
|
+
that `Client_Rate` exists in prod/dev-sandbox/client-sandbox but **not** client-beta, that
|
|
172
|
+
`_underscore` is cloned in **prebuild** (so a framework-only change needs the consuming tier
|
|
173
|
+
**redeployed**, not restarted), and that `api2` has **no deploy workflow** — EB deploys are
|
|
174
|
+
manual. (mhammontree)
|
|
142
175
|
- 2026-07-28 — Manual LB re-registration is now automated on **non-production** tiers carrying the `.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh` hook pair (api2 already had it; ported to worker2 this session). Linked the new worker2 feature doc as the reference implementation and flagged that api2's copy has known security defects. (jcardinal)
|
|
143
176
|
- 2026-07-27 — Noted that not every EB env pulls `_underscore` from its `_<env>` branch: **`API-Sandbox-Dev`** re-clones `_underscore` from the branch in `.ebextensions/git.sandbox-dev.json` (**`_sandbox-dev`**), overwriting the pipeline copy — so a `_beta` merge doesn't reach it. Added the `Logs_<Client>.Api` server-side request-log note as the reliable way to debug a beta env without Sentry (TRUE-79533). (mhammontree)
|
|
144
177
|
- 2026-07-14 — Documented the branch model: app repos (`_underscore`/`api2`/`toga2-supply`) deploy from long-lived `_beta`/`_production` branches (not `_main`); api2 pulls `_underscore`'s `_<env>` branch at EB build; `dbchanges2` has only `_main` and its migrations are applied per-env by the team process (not by a code deploy). (mhammontree)
|
|
@@ -6,7 +6,7 @@ project: Database Changes
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: architecture
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
9
|
+
updated: 2026-07-31
|
|
10
10
|
owners: [jcardinal, mhammontree, bala, ajean]
|
|
11
11
|
files:
|
|
12
12
|
- Core/
|
|
@@ -43,6 +43,9 @@ UI config) — **not just schema**; a schema-only blank produces non-functional
|
|
|
43
43
|
`Archive_*`, `Logs*`, and `Cache` are on **separate production clusters**, so any
|
|
44
44
|
`OtherDatabase.Table` reference is unrunnable in production even though it works locally
|
|
45
45
|
(see *Database isolation* below; enforced by the `dbchanges2-cluster-isolation` hook).
|
|
46
|
+
**`Core.Records` and `Core.RecordFields` are the only two tables platform-wide with
|
|
47
|
+
team-maintained `id`s** — ask the developer for the next value before inserting (never
|
|
48
|
+
`AUTO_INCREMENT`), and hardcode those `id`s wherever they are referenced, foreign keys included.
|
|
46
49
|
|
|
47
50
|
## File naming convention (the execution contract)
|
|
48
51
|
|
|
@@ -189,6 +192,61 @@ literals are stripped first, and table **aliases** (`rf.id`, `sibling.roleId`) a
|
|
|
189
192
|
> is **not** subject to it. If the hook ever misfires on legitimate SQL, `DBCHANGES2_ISOLATION_DISABLED=1`
|
|
190
193
|
> is an escape hatch **for false positives only** — never to land a cross-database query.
|
|
191
194
|
|
|
195
|
+
## `Core.Records` / `Core.RecordFields` — the only hardcoded `id`s on the platform
|
|
196
|
+
|
|
197
|
+
These **two tables, and only these two**, have **team-maintained primary keys**. Their `id`
|
|
198
|
+
values are treated as **stable, platform-wide constants**: the team tracks the next available
|
|
199
|
+
value **in the developer chat**, and every environment carries the same `id` for the same record
|
|
200
|
+
/ field. Nothing else on the platform may have a hardcoded `id`.
|
|
201
|
+
|
|
202
|
+
This is what makes the *Database isolation* rule above practical — it is the reason a
|
|
203
|
+
`Client_<Tenant>` migration never needs to read `Core` to find a `recordFieldId`.
|
|
204
|
+
|
|
205
|
+
### Inserting into them — ASK FIRST, never rely on AUTO_INCREMENT
|
|
206
|
+
|
|
207
|
+
**When a change inserts into `Core.Records` or `Core.RecordFields`, stop and ask the developer
|
|
208
|
+
for the next `id` value(s)**, then write them as explicit literals:
|
|
209
|
+
|
|
210
|
+
```sql
|
|
211
|
+
# Core/2026-07-31a - Add entitlements serviceAddressId field.sql
|
|
212
|
+
# id values 4187, 4188 assigned by the team (developer chat) — do NOT let AUTO_INCREMENT pick.
|
|
213
|
+
INSERT INTO RecordFields (id, uuid, recordId, `field`)
|
|
214
|
+
VALUES (4188, '<pre-generated v4 uuid>', 219, 'serviceAddressId');
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**Never omit `id` and let MySQL's `AUTO_INCREMENT` assign it.** The column's `AUTO_INCREMENT`
|
|
218
|
+
definition still exists, but it is **not the source of truth** — allowing it to assign a value
|
|
219
|
+
lets environments drift apart, and every hardcoded reference to that `id` (in other databases,
|
|
220
|
+
in PHP, in other migrations) then points at the wrong row or nothing at all. There is no way to
|
|
221
|
+
detect this from inside the migration; it simply produces silently wrong ACL/field wiring.
|
|
222
|
+
|
|
223
|
+
Because the `id`s must be reserved by a human, **this cannot be guessed or derived** — asking is
|
|
224
|
+
mandatory, not a courtesy. Record the assigned values in a comment at the top of the file so the
|
|
225
|
+
next reader knows they were allocated, not invented.
|
|
226
|
+
|
|
227
|
+
### Referencing them — hardcode the `id`, including foreign keys
|
|
228
|
+
|
|
229
|
+
Anywhere a query references one of these rows — **and anywhere a foreign key points at them**
|
|
230
|
+
(`recordId`, `recordFieldId`, and their equivalents) — **hardcode the numeric `id`**. Do not look
|
|
231
|
+
it up:
|
|
232
|
+
|
|
233
|
+
```sql
|
|
234
|
+
# Client_Compass/2026-07-31a - Grant serviceAddress field write.sql
|
|
235
|
+
# recordFieldId 4188 = Core.RecordFields entitlements.serviceAddressId (team-assigned constant)
|
|
236
|
+
INSERT INTO AclFieldPermissions (uuid, recordFieldId, roleId, isWritable)
|
|
237
|
+
VALUES ('<pre-generated v4 uuid>', 4188, 3, 1);
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
This applies equally to SQL embedded in PHP in `worker2` / `api2` / `_underscore` — a hardcoded
|
|
241
|
+
`recordFieldId` is correct and preferred there too.
|
|
242
|
+
|
|
243
|
+
> **Always comment what the number is.** A bare `4188` is unreadable and unverifiable six months
|
|
244
|
+
> later. Name the record/field it refers to, as above.
|
|
245
|
+
|
|
246
|
+
Note that the `uuid` on these rows is **also** stable and environment-consistent, so
|
|
247
|
+
`WHERE uuid = '<literal>'` is a valid alternative when a readable key is preferred — but the
|
|
248
|
+
`id` is the team-maintained one, and it is what foreign keys store.
|
|
249
|
+
|
|
192
250
|
## `_modules` — reusable, opt-in change-sets
|
|
193
251
|
|
|
194
252
|
Some change-sets aren't applied to every client — only to clients that use a given **module**
|
|
@@ -295,6 +353,12 @@ its own header.)
|
|
|
295
353
|
foreign ids with a hardcoded v4 UUID literal or an in-database slug/natural key, or split the
|
|
296
354
|
work into one file per database folder. See *Database isolation* above — enforced by the
|
|
297
355
|
`dbchanges2-cluster-isolation` hook.
|
|
356
|
+
9. **Inserting into `Core.Records` or `Core.RecordFields`? ASK the developer for the next `id`.**
|
|
357
|
+
These are the **only two tables on the platform with team-maintained primary keys** (tracked in
|
|
358
|
+
the developer chat). Write the assigned `id` as an explicit literal — **never** let
|
|
359
|
+
`AUTO_INCREMENT` assign it. Conversely, **always hardcode** these `id`s where they are
|
|
360
|
+
referenced, including in foreign keys (`recordId`, `recordFieldId`) from other databases. See
|
|
361
|
+
*`Core.Records` / `Core.RecordFields`* above.
|
|
298
362
|
|
|
299
363
|
## Bulk data loads — batch, and stage large sets in a temp table
|
|
300
364
|
|
|
@@ -447,7 +511,16 @@ defined in `2.0/apps/_underscore/architecture.md`, and its change files create/a
|
|
|
447
511
|
tables that `_Model_*` classes map to.
|
|
448
512
|
|
|
449
513
|
## Change history
|
|
450
|
-
- 2026-07-
|
|
514
|
+
- 2026-07-31 — **Added *`Core.Records` / `Core.RecordFields` — the only hardcoded `id`s on the
|
|
515
|
+
platform* + rule #9.** These two tables are the **only** ones platform-wide whose `id` is a
|
|
516
|
+
team-maintained constant: the next available value is tracked **in the developer chat**, so a
|
|
517
|
+
migration inserting into them must **ask the developer for the `id`** and write it as an explicit
|
|
518
|
+
literal — `AUTO_INCREMENT` must never assign it (silent per-environment drift breaks every
|
|
519
|
+
hardcoded reference). Conversely these `id`s **should** be hardcoded wherever referenced,
|
|
520
|
+
including foreign keys (`recordId`, `recordFieldId`) from client databases and in PHP query
|
|
521
|
+
strings. This is what makes *Database isolation* workable — no client migration needs to read
|
|
522
|
+
`Core` to resolve a `recordFieldId`. Enforced by the `dbchanges2-record-ids` hook. (jcardinal)
|
|
523
|
+
- 2026-07-31 — **Added *Database isolation — never query across databases* (HARD RULE) + rule #8.**
|
|
451
524
|
A `dbchanges2` `.sql` file may only reference tables in the one database its folder targets, and
|
|
452
525
|
must reference them unqualified; fan-out folders (`Client/`, `Logs_Client/`, `_modules/`) permit
|
|
453
526
|
no database qualifier at all. Rationale: in production `Core`, `Client_<Tenant>`,
|