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.
@@ -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 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 |
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-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. New scripted routes should follow
72
- this pattern (route + `AclRecordScripts` both in `dbchanges2`).
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-28
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
- related: []
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-29
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-29 — **Added *Database isolationnever query across databases* (HARD RULE) + rule #8.**
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>`,