toga-ai 1.0.535 → 1.0.537

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.
Files changed (48) hide show
  1. package/knowledge/1.0/apps/library/features/startech-pcmaticb2b-sync.md +37 -15
  2. package/knowledge/1.0/apps/library/features/toga2-api-client-and-bridge.md +9 -1
  3. package/knowledge/1.0/standards/backend-php.md +40 -29
  4. package/knowledge/1.0/standards/framework-rules.md +9 -5
  5. package/knowledge/2.0/apps/_underscore/INDEX.md +2 -1
  6. package/knowledge/2.0/apps/_underscore/features/acl-permission-chain.md +62 -0
  7. package/knowledge/2.0/apps/_underscore/features/config-group-access.md +95 -0
  8. package/knowledge/2.0/apps/_underscore/features/error-reporting-issue-event.md +60 -2
  9. package/knowledge/2.0/apps/_underscore/features/model-magic-field-access.md +42 -2
  10. package/knowledge/2.0/apps/_underscore/features/tracking-number-bridges.md +22 -2
  11. package/knowledge/2.0/apps/_underscore/features/units-for-items-for-purchase-orders.md +17 -3
  12. package/knowledge/2.0/apps/_underscore/workflows/local-db-refresh-from-beta.md +15 -2
  13. package/knowledge/2.0/apps/api2/INDEX.md +1 -1
  14. package/knowledge/2.0/apps/api2/features/environment-variable-drives-underscore-branch.md +47 -2
  15. package/knowledge/2.0/apps/api2/features/record-scripts.md +49 -12
  16. package/knowledge/2.0/apps/api2/features/v2-api-error-codes.md +57 -2
  17. package/knowledge/2.0/apps/toga2-commerce/INDEX.md +1 -1
  18. package/knowledge/2.0/apps/toga2-commerce/features/client-fields.md +42 -2
  19. package/knowledge/2.0/apps/toga2-supply/INDEX.md +1 -1
  20. package/knowledge/2.0/apps/toga2-supply/workflows/client-host-scoping.md +82 -5
  21. package/knowledge/2.0/apps/toga2-view/INDEX.md +1 -1
  22. package/knowledge/2.0/apps/toga2-view/features/get-support-cancel-subscription.md +22 -1
  23. package/knowledge/2.0/apps/worker2/features/creating-worker-actions.md +11 -0
  24. package/knowledge/2.0/standards/backend-testing.md +80 -1
  25. package/knowledge/INDEX.md +4 -4
  26. package/knowledge/clients/compass-canada/profile.md +15 -1
  27. package/knowledge/clients/compass-usa/INDEX.md +3 -0
  28. package/knowledge/clients/compass-usa/features/persona-model-and-levy-gating.md +132 -0
  29. package/knowledge/clients/compass-usa/profile.md +17 -1
  30. package/knowledge/clients/compass-usa/workflows/persona-population-env-comparison.md +82 -0
  31. package/knowledge/clients/compass-usa/workflows/persona-refactor-migration.md +138 -0
  32. package/knowledge/clients/elite/INDEX.md +2 -1
  33. package/knowledge/clients/elite/features/supply2-scope.md +65 -23
  34. package/knowledge/clients/elite/features/supply2-tableview-config-drift.md +107 -0
  35. package/knowledge/clients/elite/profile.md +19 -2
  36. package/knowledge/clients/nycdoe/features/servicenow-integration.md +75 -1
  37. package/knowledge/clients/quad/INDEX.md +1 -0
  38. package/knowledge/clients/quad/features/po-asn-email-import.md +177 -0
  39. package/knowledge/clients/quad/profile.md +16 -2
  40. package/knowledge/clients/rate/INDEX.md +2 -2
  41. package/knowledge/clients/rate/features/aig-contract-creation.md +53 -1
  42. package/knowledge/clients/rate/features/paypal-subscription-purchase-webhook.md +117 -18
  43. package/knowledge/clients/rate/features/subscription-cancellation.md +162 -3
  44. package/knowledge/sessions/2026-08-06-TRUE-80282-cancel-subscription-mhammontree.md +91 -0
  45. package/knowledge/sessions/2026-08-06-elite-inventory-fe-tcox.md +80 -0
  46. package/knowledge/sessions/2026-08-06-quad-asn-import-jam-ajean.md +256 -0
  47. package/knowledge/standalone/apps/claude/workflows/mcp-tool-usage.md +12 -2
  48. package/package.json +1 -1
@@ -6,8 +6,8 @@ project: Library
6
6
  client: pcmaticb2b
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-07-29
10
- owners: [snaredla]
9
+ updated: 2026-08-10
10
+ owners: [snaredla, mhammontree]
11
11
  files:
12
12
  - library/app/api/toga2.php
13
13
  - library/app/api/startechticket.php
@@ -59,6 +59,32 @@ $payload['ticketType'] = ['code' => $toga2TicketTypeCode];
59
59
 
60
60
  Both `PCS Ticket` and `TOGaDesk-PCS` map to `PCS` in TOGA 2.0.
61
61
 
62
+ ## Onboarding a new StarTech client — `STARTECH_TOGADESK_CLIENTS`
63
+
64
+ `App_Api_Toga2::STARTECH_TOGADESK_CLIENTS` (`library/app/api/toga2.php`, top of the class) is the
65
+ registry that identifies StarTech-integrated clients. It maps **TOGaDesk client id => the client's
66
+ TOGA 2.0 API credentials**:
67
+
68
+ ```php
69
+ const STARTECH_TOGADESK_CLIENTS = [
70
+ App_Model_TogaDesk_Ticket::TOGADESK_CLIENT_ID__PCMATICB2B => [
71
+ 'clientUuid' => self::CLIENT_UUID_PCMATICB2B,
72
+ 'apiUuid' => self::API_UUID_PCMATICB2B,
73
+ 'apiSecret' => self::API_SECRET_PCMATICB2B,
74
+ ],
75
+ ];
76
+ ```
77
+
78
+ - **A new row must be added per client onboard.** Consumers (e.g.
79
+ `getStartechSelectableTicketStageNames`) look the client up here and **short-circuit to an empty
80
+ result with no DB hit** when absent — so a missing row is a silent no-op (an empty status
81
+ dropdown), not an error. This is the operational step to remember.
82
+ - The row's values are references to the per-client `CLIENT_UUID_*` / `API_UUID_*` /
83
+ `API_SECRET_*` class constants declared at the top of the same file — that is **where** the
84
+ credentials live. Do not reproduce their values in the KB, tickets, or logs (see the hardcoded-credentials
85
+ gotcha in [toga2-api-client-and-bridge](./toga2-api-client-and-bridge.md)).
86
+ - Currently **one** row: TOGaDesk client **177** (PC Matic B2B).
87
+
62
88
  ## Gotchas
63
89
 
64
90
  - **Do not add a NULL early-return guard in this function.** NULL `c_escalateToStartech` means the ticket was created in TOGaDesk — it must still sync to TOGA 2.0. The Startech gate lives in `_Trait_Startech_Ticket::postPost`, not here.
@@ -93,16 +119,12 @@ Imports Startech (Easeedesk V3) config into TOGA 2.0 — users, groups, devices,
93
119
  the 1.0 → 2.0 payload. Both reference the client's 2.0 schema **by name** (from
94
120
  `App_Model_Client::getClientDatabaseNames()`) so the JOIN stays inside one client schema — do not use
95
121
  `qqJoinClientsTable(…, true)` here, its UNION-across-all-clients form would cross-match ids between
96
- clients. StarTech clients are identified by `App_Api_Toga2::STARTECH_TOGADESK_CLIENTS`
97
- (TOGaDesk client id => TOGA 2.0 client id); non-StarTech clients short-circuit with no DB hit.
98
- - 2026-07-29: Added two per-ticket-type stage helpers to `App_Api_Toga2` (see
99
- `clients/pcmaticb2b/features/startech-per-ticket-type-stages.md`):
100
- `getStartechSelectableTicketStageNames($togadeskClientId, $togadeskDepartmentId)` resolves the
101
- agent-selectable stage names for TOGaDesk's status dropdown (department → ticket type via
102
- `TicketTypes.c_togadeskTicketDepartmentId`, filtered by `c_isSelectable = 1`), and
103
- `getStartechTicketStageUuid($ticketTypeName, $ticketStageName)` resolves a stage within a type for
104
- the 1.0 → 2.0 payload. Both reference the client's 2.0 schema **by name** (from
105
- `App_Model_Client::getClientDatabaseNames()`) so the JOIN stays inside one client schema — do not use
106
- `qqJoinClientsTable(…, true)` here, its UNION-across-all-clients form would cross-match ids between
107
- clients. StarTech clients are identified by `App_Api_Toga2::STARTECH_TOGADESK_CLIENTS`
108
- (TOGaDesk client id => TOGA 2.0 client id); non-StarTech clients short-circuit with no DB hit.
122
+ clients. StarTech clients are identified by `App_Api_Toga2::STARTECH_TOGADESK_CLIENTS`;
123
+ non-StarTech clients short-circuit with no DB hit.
124
+ - 2026-08-10: **Corrected** the description of `STARTECH_TOGADESK_CLIENTS` — it maps TOGaDesk client
125
+ id to the client's **TOGA 2.0 API credentials** (`clientUuid`/`apiUuid`/`apiSecret`, referencing the
126
+ `CLIENT_UUID_*`/`API_UUID_*`/`API_SECRET_*` class constants), **not** to a TOGA 2.0 client id as the
127
+ two 2026-07-29 entries stated. Added the onboarding section: a row must be added per client onboard
128
+ or the client silently short-circuits (empty stage list, no error). Also deduplicated the
129
+ accidentally-doubled 2026-07-29 entry. Observed while resolving a `_production` merge on TRUE-79401;
130
+ the const itself was authored by a teammate. (mhammontree)
@@ -6,7 +6,7 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-05
9
+ updated: 2026-08-10
10
10
  owners: [jcardinal, mhammontree, bala]
11
11
  files:
12
12
  - library/app/api/toga2.php
@@ -213,6 +213,10 @@ tickets) was **explicitly rejected** — see the gotcha about the single-recipie
213
213
  intact under a large merge. **Still open: whether it is deployed to the prod worker.**
214
214
  Review technique worth reusing: `git diff _production...HEAD -- <file>` isolates a ticket's real
215
215
  contribution from merge noise when an old branch is finally synced.
216
+ **Re-synced 2026-08-10** (`c64164fe`, pushed): `_production` merged into `TRUE-79401` again; the
217
+ only conflict was a positional collision in the class const block of `app/api/toga2.php` (this
218
+ ticket's `CUSTOMER_EMAIL_DELIMITER` landing next to incoming consts) — resolved by keeping both,
219
+ no logic change. Patch still intact; **deploy to the prod worker remains unverified.**
216
220
  2. **Beta verification of the nested write is outstanding.** Assert real
217
221
  `Client_Aig.ContactEmailAddresses` **row counts** (never the HTTP status), covering **both** a
218
222
  brand-new contact (CREATE) **and** a second entitlement for an **existing** contact (UPDATE).
@@ -361,6 +365,10 @@ enable flags** and an optional `$monitorTogadeskDepartmentIds[]`:
361
365
 
362
366
  ## Change history
363
367
 
368
+ - 2026-08-10 — TRUE-79401 re-synced with `_production` (`c64164fe`, pushed); the sole conflict was a
369
+ positional const-block collision, resolved by keeping both sides. Patch intact; deploy still
370
+ unverified. `STARTECH_TOGADESK_CLIENTS` (credential registry, add a row per client onboard) is
371
+ documented on [startech-pcmaticb2b-sync](./startech-pcmaticb2b-sync.md). (mhammontree)
364
372
  - 2026-08-05 — Recorded that a 2.0 GET **silently truncates a nested child collection** (observed
365
373
  `itemFulfillmentItemUnits` 45 of 70) with no error — so verification/counts must read the child
366
374
  resource **flat + fully paginated** and use `meta.totalRecordCount`, never a single nested
@@ -5,8 +5,8 @@ project: Library
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-08-05
9
- owners: [jcardinal, rgirish, mhammontree]
8
+ updated: 2026-08-06
9
+ owners: [jcardinal, rgirish, mhammontree, ajean]
10
10
  files: []
11
11
  related:
12
12
  - ../apps/library/architecture.md
@@ -442,33 +442,44 @@ class Browser_Datagrid_Accounts extends Browser_Datagrid {
442
442
  * The `@` error-suppression operator is acceptable only for defensive file I/O (e.g. reading an optional cache file); do not use it to hide real errors.
443
443
  * Use structured logging to capture application behavior and errors.
444
444
 
445
- ### PHP warnings throw — guard fragile I/O, do not rely on `!== false`
446
-
447
- The bootstrap sets `error_reporting(E_ALL)` and `App_Error::handleError` (`library/app/error.php`)
448
- converts **every** PHP warning into an `ErrorException`. Consequences you must design around:
449
-
450
- - Warning-emitting calls **throw at the call site** rather than returning their documented
451
- failure value. `file_get_contents()` on an unreachable/403/timed-out URL raises an
452
- `ErrorException` **inside** the call — so a following `if ($x !== false)` guard never runs.
453
- - In a loop (e.g. a cron), one such throw aborts the **entire** remaining batch, not just the
454
- current item. This is especially dangerous in two-phase "stamp then act" crons where earlier
455
- phases have already committed state.
456
-
457
- Wrap fragile I/O and return the failure value yourself:
458
-
459
- ```php
460
- function safeFetchPdf(string $url) {
461
- try {
462
- return file_get_contents($url);
463
- } catch (\ErrorException $e) {
464
- error_log('PDF fetch failed: ' . $e->getMessage());
465
- return false;
466
- }
467
- }
468
- ```
469
-
470
- Then guard each dependent step **independently** (not a joint `&&`) so one failure degrades
471
- gracefully instead of dropping unrelated work or crashing the loop.
445
+ ### A notice/warning cannot be contained by `try/catch` — and it jams queues
446
+
447
+ **Canonical mechanics are above:** see
448
+ [*Any PHP warning terminates the request — how to opt out locally*](#any-php-warning-terminates-the-request--how-to-opt-out-locally).
449
+ `App_Error::handleError` does **not** throw; it calls `handleException`, which `exit`s. Two
450
+ sanctioned opt-outs (a local `set_error_handler`, or
451
+ `App_Error::setThrowExceptionsEnabled(false)` with a `finally` restore) are documented there.
452
+ This section records what that means for **job/queue code** specifically.
453
+
454
+ Additional verified detail:
455
+
456
+ - **Both exit paths.** `handleException` ends in `exit` whether Sentry is absent
457
+ (`library/app/error.php` ~L407) or present (~L671). Apps that call `\Sentry\init` in
458
+ `_/app/framework.php` — `worker` included — take the Sentry path, so "Sentry is configured"
459
+ does **not** mean execution continues.
460
+ - **The `return false;` after `exit;` in `handleError` is unreachable dead code.** Do not read
461
+ it as a signal that the handler returns control to the call site.
462
+ - **A notice jams a queue exactly like an uncaught exception would.** In an inbox- or
463
+ queue-driven loop, any statement *after* the failure point — an ack, an
464
+ `$imap->deleteMessage()`, a status stamp — never executes, so the same item is re-read and
465
+ re-fails on every run, forever. Confirmed against production evidence: after the notice
466
+ fired, the importer made **zero** further api2 calls in that run.
467
+ - **Diagnostic fingerprint:** an occurrence count that is an exact multiple of the job's
468
+ scheduled frequency (e.g. 20/hour for `*/3 * * * *`) means **one** poisoned item retried,
469
+ not many distinct failures.
470
+ - **In job code, prevention beats containment.** Guard optional data with `isset()` before
471
+ reading it, and consume/ack the item in a `finally` (or under a `catch (Throwable)`) so a
472
+ genuine exception cannot strand it. You cannot wrap your way out of a notice.
473
+
474
+ > **Existing guidance under review.** An earlier version of this section recommended
475
+ > wrapping fragile I/O in `try { ... } catch (\ErrorException $e)` relying on the GLOBAL
476
+ > handler to convert a warning. Given the `exit` above, that cannot work for a PHP
477
+ > warning or notice — the process ends before the `catch`. Uses of that pattern need
478
+ > **re-verifying** rather than assuming they are safe or assuming they are broken: the
479
+ > call may be throwing a genuine exception, or installing a local handler. Known
480
+ > occurrences to check: `clients/prudential/features/transmit-ordershipped-email.md`
481
+ > (~L112-123), `clients/prudential/profile.md` (~L75). Prefer the local-handler pattern
482
+ > from the canonical section above for anything new.
472
483
 
473
484
  ## Security Best Practices
474
485
 
@@ -5,8 +5,8 @@ project: Library
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-06-16
9
- owners: [jcardinal]
8
+ updated: 2026-08-06
9
+ owners: [jcardinal, ajean]
10
10
  files: []
11
11
  related:
12
12
  - ../apps/library/architecture.md
@@ -114,9 +114,13 @@ repos is only query strings embedded in PHP code — not standalone migration fi
114
114
 
115
115
  ## PHP 8.x deprecations abort the request (audit before running on 8.5)
116
116
 
117
- The 1.0 framework's error handler promotes PHP **deprecation notices to `ErrorException`**,
118
- which aborts the request. On PHP 8.5, a single deprecated call in a live code path is therefore
119
- a hard failure, not a silent notice — it surfaces to users as a generic error.
117
+ The 1.0 framework's error handler **terminates** on a PHP deprecation notice
118
+ (`App_Error::handleError` → `handleException` → `exit`; it does not throw a catchable
119
+ `ErrorException`), so the request/job dies where the deprecation fires. On PHP 8.5 a single
120
+ deprecated call in a live code path is therefore a hard, uncontainable failure — a
121
+ surrounding `try/catch` will not save it. See the back-end standard's
122
+ *"Any PHP warning terminates the request — how to opt out locally"* section for the two
123
+ sanctioned local opt-outs.
120
124
 
121
125
  - When porting or running 1.0 code on PHP 8.x, **audit for deprecated-in-8.x calls** in every
122
126
  reachable code path before deploy.
@@ -14,6 +14,7 @@
14
14
  | [Running 2.0 code from a bare CLI script (bootstrap + transactions)](features/cli-script-bootstrap.md) | A throwaway CLI script (a data check, a backfill dry-run, a render harness) that wants the real 2.0 framework — `_Model`, `_Query`, `_Database` — is **not** the | _underscore/Database.php, _underscore/Environment.php, api2/Initialize.php |
15
15
  | [_Cloud S3 helpers (copy / get / delete / list)](features/cloud-s3-helpers.md) | `_Cloud` centralizes AWS SDK S3 usage for the 2.0 stack so the `S3Client` never leaks into workers or app code. | _underscore/Cloud.php |
16
16
  | [_Component_*/_Model_* project-namespace registration (autoloader) & backslash-qualify traps](features/component-model-namespace-registration.md) | Every **project-local** `_Component_*` and `_Model_*` class in a 2.0 app **must declare the project namespace** at the top of the file: ```php namespace <NAMESP | _underscore/Loader.php, worker2/_.php, api2/_.php, worker2/Component/Forecast/Db/Db.php, worker2/Component/Forecast/SaleImport/SaleImport.php, api2/Component/Api/Netsuite/Netsuite.php, _underscore/Component/Api/Paypal/Paypal.php |
17
+ | [_Config group access — the two-argument "optional" form does NOT rescue a missing GROUP](features/config-group-access.md) | `_Config::<group>('<property>')` reads a value out of the active `Config/<ENVIRONMENT>.ini`, and **it throws when the requested group is absent from the ini.** | _underscore/Config.php, _underscore/Component/Api/Paypal/Paypal.php |
17
18
  | [Re-pointing a DB alias mid-request (_Database::register park/restore)](features/database-alias-repointing.md) | `_Database` keys **all live per-database runtime state by the connection ALIAS** (`Client` / `_underscore::DB_CLIENT`, `ClientLogs`, `Archive`), **not** by the | _underscore/Database.php, _underscore/Query.php, api2/Component/Api/V2/V2.php, api2/Component/Api/CrossClient/CrossClient.php |
18
19
  | [2.0 Email Send Pipeline (queue + Send worker)](features/email-send-pipeline.md) | In 2.0, `_Email::send()` **does not transmit** — it queues the message. | _underscore/Email.php, _underscore/String.php, worker2/Worker/Infrastructure/Email/Send.php |
19
20
  | [Client Email Template Sending](features/email-template-sending.md) | `_Model_Client_EmailTemplate` sends a stored, client-defined email template by UUID. | _underscore/Model/Client/EmailTemplate.php, _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php, _underscore/Email.php |
@@ -23,7 +24,7 @@
23
24
  | [isFulfillable Propagation Up the SO↔PO Chain](features/fulfillable-item-propagation.md) | `Items.isFulfillable` is a boolean that gates whether a storefront line's **Qty Fulfilled** cell is actionable. | _underscore/Model/Client/Item.php, _underscore/Model/Compass/Item.php, dbchanges2/Core/2026-07-17 - Items isFulfillable RecordField.sql, dbchanges2/Core/2026-07-17 - RegisterItemIsFulfillableInterceptors.sql, dbchanges2/Client/2026-07-17 - ItemsisFulfillable.sql |
24
25
  | [Item-Fulfillment Stage Lifecycle (picked/packed/shipped) & Order Status](features/item-fulfillment-stage-lifecycle-and-order-status.md) | Every ItemFulfillment (IF) now carries an explicit **stage** — picked → packed → shipped — resolved through `ItemFulfillmentStages → ItemFulfillmentStatuses` (m | _underscore/Model/Client/SalesOrder.php, _underscore/Model/Quad/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderStatus.php, _underscore/Model/Client/SalesOrderItem.php, _underscore/Model/Client/Item.php, _underscore/Model/Client/PurchaseOrderItem.php, library/app/api/toga2.php, dbchanges2/Client/2026-06-30a - BackfillNullStageItemFulfillmentsToShipped.sql, dbchanges2/Client/2026-06-30b - SalesOrderStatusesPickedPacked.sql, dbchanges2/Client/2026-06-30c - ItemFulfillmentStageIdNotNull.sql, dbchanges2/Client_CompassCanada/2026-06-30a - ItemFulfillmentLifecycleAndShippedBackfill.sql |
25
26
  | [DB-free unit testing for _underscore model interceptors](features/model-interceptor-unit-testing.md) | `_underscore` shipped with **no** PHPUnit setup (no `composer.json`/`phpunit`; only vendored PhpOffice tests existed). | _underscore/Test/bootstrap.php, _underscore/Test/Prudential/ServiceRequestTest.php |
26
- | [_Model magic-field access (__get without __isset)](features/model-magic-field-access.md) | `_Model` exposes DB columns as "magic" properties via `__get()`, but it defines **no** `__isset()`. | _underscore/Model/Core/Model.php |
27
+ | [_Model magic-field access (__get without __isset)](features/model-magic-field-access.md) | `_Model` exposes DB columns as "magic" properties via `__get()`, but it defines **no** `__isset()`. | _underscore/Model/Core/Model.php, _underscore/Model.php, _underscore/Model/Rate/Subscription.php |
27
28
  | [_Model::save() vs raw _Query — no atomic conditional update](features/model-save-vs-query-atomic-update.md) | `_Model::save()` is a plain load-then-write ORM primitive and **cannot express an atomic conditional update** (an optimistic-concurrency / row-claim guard such | _underscore/Model.php, _underscore/Query.php, _underscore/Model/Rate/Subscription.php |
28
29
  | [NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL](features/netsuite-rest-client.md) | `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive every worker2/api2 NetSuite caller uses for record GETs, Suit | _underscore/Component/Api/Netsuite/Netsuite.php |
29
30
  | [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/Query.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php, api2/Controller/Index.php |
@@ -272,6 +272,52 @@ empty with no error anywhere**. The same audit found `lengthMeasureId`/`weightMe
272
272
  **role 3 only, not role 1** — grant fan-out is per-client and must be verified per client, not
273
273
  assumed from one.
274
274
 
275
+ ## ⚠ `appId` / `indirectRecordId` BOTH NULL = a grant with NO SCOPING AT ALL
276
+
277
+ The optional `appId` and `indirectRecordId` columns on `AclRecordPermissions` are what narrow a
278
+ grant to an app and to a related-record path. **When both are NULL the grant is unscoped**, and
279
+ combined with an `AclRecordExpressions` `sqlExpression = '1'` there is *nothing left* restricting
280
+ which rows the role reaches. Read those two columns as part of every grant review — an unscoped row
281
+ looks identical to a scoped one at a glance.
282
+
283
+ **Why this is easy to get catastrophically wrong: the `Base` role is not a low-privilege role.**
284
+ Every portal end-customer carries `Base`, and so do API service users. A grant to `Base` is
285
+ effectively a grant to *the public*, and `AclFieldPermissions.isWritable = 1` on a business-critical
286
+ column then makes that column **customer-writable through ordinary CRUD** — completely independently
287
+ of any scripted endpoint's server-side logic.
288
+
289
+ **This is why a record script must do its own ownership check.** A scripted endpoint that computes a
290
+ value server-side (precisely so the customer cannot choose it) is **not** protected if plain
291
+ `PUT /v2/<record>/{uuid}` can write the same column. The explicit ownership check inside the script
292
+ is load-bearing, not defensive coding.
293
+
294
+ **Audit recipe.** For any record exposed to end customers, list every `AclRecordPermissions` row
295
+ with its `appId`/`indirectRecordId`, then cross it with `AclFieldPermissions.isWritable = 1`. The
296
+ dangerous combination is *unscoped record grant × writable business column*. Do it **per
297
+ environment** — grant fan-out is per-client and per-environment (see the audit notes above).
298
+
299
+ ## Verified: role UNION means a service user inherits `Base`
300
+
301
+ A row in `Client_<Slug>.Apis` can carry **more than one role**, and its effective permissions are
302
+ the **union**. Confirmed 2026-08-06 in `Client_Rate`: the `Apis` row for the worker's API uuid
303
+ carries **both `Base` and `API`**, so the worker inherits every `Base` field grant. Practical
304
+ consequence, and the reason the "mirror a sibling field" practice works: **granting a column to
305
+ `Base` also grants it to the worker.** Conversely, when auditing what an end customer can reach,
306
+ remember the reverse — a grant added "for the worker" on `Base` reaches customers too.
307
+
308
+ ## Client-DB grant migrations are re-runnable NO-OPs, and that is the design
309
+
310
+ A `NOT EXISTS`-guarded `AclFieldPermissions` insert that finds its grants already present inserts
311
+ nothing and exits clean. **That is a success, not a wasted migration** — the file still protects a
312
+ freshly-created blank client DB, and it is safe to re-run in every environment. Do not "clean up"
313
+ such a file because it no-ops where you tested it.
314
+
315
+ Distinguish this carefully from the **broken** silent no-op: a `Client_*` migration that
316
+ cross-schema-subselects `Core` also inserts zero rows without erroring, but for the wrong reason.
317
+ Live re-confirmation 2026-08-06: a `Client_Rate` → `Core` JOIN on the production cluster is
318
+ **refused outright — `Unknown database 'Core'`**. That is exactly why `Client_*` migrations must use
319
+ hardcoded `Core.RecordFields` id literals instead of a subselect.
320
+
275
321
  ## Checklist (so the chain is never half-built)
276
322
 
277
323
  - [ ] `AclRecordPermissions` row for the role × record with the right CRUD flags.
@@ -279,12 +325,28 @@ assumed from one.
279
325
  - [ ] `AclLogicGroups` row with `operator` set (AND/OR) referencing the permission.
280
326
  - [ ] `AclLogicGroupExpressions` row binding the group to the expression.
281
327
  - [ ] `AclFieldPermissions` rows for every field that must be readable/writable.
328
+ - [ ] **`appId` / `indirectRecordId` are set** unless a genuinely unscoped grant is intended — both
329
+ NULL plus `sqlExpression='1'` means no row scoping whatsoever (see the section above).
330
+ - [ ] **No business-critical column is `isWritable = 1` for `Base`** unless customers are genuinely
331
+ meant to write it via plain CRUD.
282
332
  - [ ] For `aclDatabase = 'CLIENT'` records: rows go in client DB(s); resolve `roleId` by subselect.
283
333
  - [ ] The field is declared on the **generated** `_underscore/Model/Client/<Name>.php` (else `EO-1`),
284
334
  and every repo is on the **same branch** so the generated model matches the DB.
285
335
 
286
336
  ## Change history
287
337
 
338
+ - **2026-08-06** — TRUE-80282 testing: recorded that **`appId` + `indirectRecordId` both NULL is a
339
+ grant with no scoping at all**, that **`Base` is not a low-privilege role** (every portal customer
340
+ and API service user carries it), and that the dangerous combination to audit for is *unscoped
341
+ record grant × `isWritable = 1` business column* — which lets a customer write via plain
342
+ `PUT /v2/<record>/{uuid}` regardless of a script's server-side computation, and is therefore why a
343
+ record script's own ownership check is load-bearing. Added the per-environment audit recipe. Also
344
+ verified that an `Apis` row can carry **multiple roles with union semantics** (`Client_Rate`'s
345
+ worker API uuid carries both `Base` and `API`, so granting a column to `Base` also grants it to the
346
+ worker), and that a `NOT EXISTS`-guarded grant migration that no-ops where the grants already exist
347
+ is **working as designed** — distinct from the broken cross-schema no-op, whose cause was
348
+ re-confirmed live: a `Client_Rate` → `Core` JOIN on the prod cluster is refused with
349
+ **`Unknown database 'Core'`**. (mhammontree)
288
350
  - **2026-08-04** — TRUE-80282: sharpened the ungranted-field failure shapes — a directly-requested
289
351
  ungranted field is **absent, not null** (so `=== null` checks miss it), while an ungranted
290
352
  **joined** field fails the **whole request** (`EZ-2`), so one grant can blank an entire page. Added
@@ -0,0 +1,95 @@
1
+ ---
2
+ title: _Config group access — the two-argument "optional" form does NOT rescue a missing GROUP
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-06
10
+ owners: ["mhammontree"]
11
+ files:
12
+ - _underscore/Config.php
13
+ - _underscore/Component/Api/Paypal/Paypal.php
14
+ related:
15
+ - ./cli-script-bootstrap.md
16
+ - ./model-magic-field-access.md
17
+ - ../../api2/features/environment-variable-drives-underscore-branch.md
18
+ - ../../../../clients/rate/features/subscription-cancellation.md
19
+ ---
20
+
21
+ ## Summary
22
+
23
+ `_Config::<group>('<property>')` reads a value out of the active
24
+ `Config/<ENVIRONMENT>.ini`, and **it throws when the requested group is absent from the ini.**
25
+ The widely-assumed "optional" second argument — `_Config::paypal('client_id', false)` — **does not
26
+ make a missing group safe.** It only rescues a missing *property inside a group that exists*.
27
+
28
+ The only reliable way to read config that may not be present in every environment is to test the
29
+ group first:
30
+
31
+ ```php
32
+ if (!\_Config::isGroupSet('paypal')) {
33
+ throw new \_Exception_Business('Payment provider is not configured for this environment.');
34
+ }
35
+ $clientId = \_Config::paypal('client_id');
36
+ ```
37
+
38
+ ## How it works — why the `false` flag is ignored for a missing group
39
+
40
+ `_Config::__callStatic` (`_underscore/Config.php:64-87`) receives the method name as the group and
41
+ `$properties` as the argument list, where `$properties[0]` is the property **name** and
42
+ `$properties[1]` is the optional/`false` flag.
43
+
44
+ The missing-group branch tests **`$properties[0]`** to decide whether the caller asked for an
45
+ optional read — but it does so **before `array_shift()` has run**, so at that moment
46
+ `$properties[0]` is still the property *name* (a non-empty string, therefore truthy), not the
47
+ `false` flag. The guard consequently never sees the caller's intent and execution falls through to
48
+ the throw at **line 85**.
49
+
50
+ So the behavior is:
51
+
52
+ | Call | `[paypal]` group missing | property missing, group present |
53
+ |---|---|---|
54
+ | `_Config::paypal('client_id')` | **throws** | throws |
55
+ | `_Config::paypal('client_id', false)` | **throws** (the flag is not consulted) | returns falsy — works as intended |
56
+
57
+ This is an off-by-one in the argument inspection, not intended design. Do not "fix" a
58
+ missing-group crash by adding the `false` flag; it will not help.
59
+
60
+ ## Why this matters more than it looks
61
+
62
+ Config groups are **not uniform across environments.** `Config/<ENVIRONMENT>.ini` files are
63
+ maintained per tier (see
64
+ [ENVIRONMENT drives the branch and Config file](../../api2/features/environment-variable-drives-underscore-branch.md)),
65
+ so a group added for one tier routinely does not exist in the others. Any code path reading a
66
+ group that isn't universal — a payment provider, a carrier account, an optional integration — is
67
+ one deploy away from throwing in a tier nobody tested.
68
+
69
+ Because this throws rather than returning null, the failure surfaces to whoever triggered the
70
+ request. Concrete case (Rate subscription cancellation, 2026-08-06): a cancel executed in any
71
+ environment without a `[paypal]` section returned **`EO-1` with a stack trace to a logged-in
72
+ retail customer** instead of failing cleanly with a business message. Guarding with
73
+ `isGroupSet()` turned it into a controlled `_Exception_Business`.
74
+
75
+ ## Gotchas / known issues
76
+
77
+ - **`_Config::<group>('prop', false)` is NOT a null-safe read.** It is property-optional only.
78
+ Group-optional requires `_Config::isGroupSet('<group>')`.
79
+ - **A missing group is an uncaught throw on the user's request**, not a silent null — so the
80
+ blast radius is a visible 500/`EO-1`, not a quiet misbehavior. Guard before you read.
81
+ - **Never guard by catching the exception broadly.** Wrap the `isGroupSet()` check in a
82
+ meaningful business error instead, so the caller learns *the environment is not configured*
83
+ rather than *something went wrong*.
84
+ - **Do not record credential values anywhere.** Document only which ini and which group a
85
+ credential lives in (e.g. `api2/Config/beta.ini` `[paypal]`).
86
+
87
+ ## Change history
88
+
89
+ - 2026-08-06 — TRUE-80282 testing: documented that `_Config::<group>()` **throws when the group is
90
+ absent** and that the two-argument "optional" form does **not** rescue a missing group —
91
+ `_Config::__callStatic` (`Config.php:64-87`) tests `$properties[0]` for the optional flag
92
+ **before `array_shift()`**, so it sees the property name (truthy) instead of `false` and reaches
93
+ the throw at line 85 anyway. `isGroupSet('<group>')` is the only working guard. Found because a
94
+ cancel in an environment with no `[paypal]` section returned `EO-1` with a stack trace to a
95
+ logged-in customer. (mhammontree)
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-04
10
- owners: ["dfranks", "jcardinal"]
9
+ updated: 2026-08-06
10
+ owners: ["dfranks", "jcardinal", "mhammontree", "ajean"]
11
11
  files:
12
12
  - _underscore/Error.php
13
13
  - _underscore/Database.php
@@ -387,8 +387,32 @@ app that has not been wired up yet.
387
387
  in `_underscore` and `library` in the **same** release, and the `/errors` console work needs
388
388
  **both** `library` and `tools` deployed before it is visible.
389
389
 
390
+ ## ⚠ OPEN DEFECT (2026-08-06) — error capture is DEAD on sandbox-dev/beta: `Logs.Issue` is missing `clickupPriority`
391
+
392
+ **Anyone debugging on that environment is flying blind.** The capture INSERT fails with **MySQL 1054
393
+ (unknown column)** because `Logs.Issue` there has `clickupAssigneeId` and `createsClickupTask` but
394
+ **not `clickupPriority`**. Consequences:
395
+
396
+ - **No error is persisted at all** — no `Logs.Issue` row, no `Logs.Event` row, no ClickUp task.
397
+ - The only trace is inline in the API response as **`identifiers.captureFailure`**. If you are
398
+ looking in the database or ClickUp for an error you just triggered and finding nothing, look at the
399
+ response body for `captureFailure` before concluding the error didn't happen.
400
+
401
+ **Cause:** `_underscore` commit `4ebe12fe` *"Improve error fingerprinting, add ClickUp priority
402
+ tracking"* shipped the code that writes the column **without its schema migration reaching that
403
+ environment.** Fix is a one-column `dbchanges2` migration in the `Logs` folder.
404
+
405
+ **The general lesson (this is the pipeline's structural weak point):** error capture is the one
406
+ subsystem whose own failure cannot be reported through itself. A schema drift here does not degrade
407
+ gracefully — it silently deletes all observability for the whole tier. Any change adding a column to
408
+ `Logs.Issue`/`Logs.Event` must land its migration in **every** environment in the same release; see
409
+ *Deploy order* above.
410
+
390
411
  ## Gotchas / known issues
391
412
 
413
+ - **⚠ A missing `Logs.Issue` column silently disables ALL error capture on that environment** —
414
+ see the open defect above. Symptom: `identifiers.captureFailure` in the API response and nothing
415
+ anywhere else.
392
416
  - **Read-your-writes: reads go to the READ host and cannot see an uncommitted write.** A
393
417
  second `$issue->save()` re-reads the row via `_Model::initialize()`, and that SELECT goes to
394
418
  the read host — a separate connection that cannot see the still-uncommitted INSERT on the
@@ -442,6 +466,23 @@ in `_underscore` and `library` in the **same** release, and the `/errors` consol
442
466
  `varchar(255)` (`errorMessage`, `subject`) **throws** on `->save()`. Inside the handler's
443
467
  `catch(Throwable)` the throw is swallowed and the row is silently dropped — precisely when a
444
468
  long message matters most. Truncate to column width before `save()`.
469
+ - **A truncated `errorMessage` reads as "the API returned nothing" — it did not.** When a
470
+ captured message is a `print_r`-dumped api2 response envelope, the `varchar(255)` cut lands
471
+ **exactly where `status`, `error.code` and `messages[]` begin**, so the console shows an
472
+ envelope with no verdict in it and the field looks empty rather than severed. (Observed: a
473
+ pasted error cut at precisely 255 chars sent an investigation after a phantom empty field.)
474
+ Escalation path when the console message stops mid-envelope:
475
+ **(1)** the **full** request/response is in the **client-scoped** `Logs_<Client>.Api`
476
+ (e.g. `Logs_Quad.Api`) — **not** core `Logs.Api`; **(2)** the original inbound payload is in
477
+ `Logs.Event.context` (for an email importer, the attachment content verbatim — this is how a
478
+ poison file was recovered). Pull `context` before the **7-day** `Event` retention purges it.
479
+ - **`_Error::buildContext` redacts the DECODED token but not the RAW response string (OPEN
480
+ security note).** `access_token` is stripped from the decoded response object while the raw
481
+ `rawResponse` / `lastResponse` strings are stored intact, so live bearer JWTs reach
482
+ `Logs.Event.context` in **cleartext**. Two consequences: the raw strings need adding to the
483
+ redactor (in `_underscore/Error.php` **and** `library/app/error/capture.php` together, per the
484
+ gotcha above), and until then **never paste a `Logs.Event.context` or `Logs_<Client>.Api` row
485
+ into a ticket, chat, or knowledge doc** — those rows carry live tokens and customer PII.
445
486
  - **Hand-written SQL is what a table rename actually breaks — and it fails *after* the model
446
487
  succeeds.** The singular rename left plural literals in hand-written statements across four
447
488
  files, so `$issue->save()` succeeded and then `UPDATE Issues …` threw **1146** before the
@@ -539,6 +580,23 @@ clientUserId). **Neither was built.** As built instead:
539
580
 
540
581
  ## Change history
541
582
 
583
+ - 2026-08-06 — Added two debugging/security notes found while unjamming the Quad PO/ASN importer:
584
+ (1) a `varchar(255)`-truncated `errorMessage` severs a dumped api2 envelope exactly at
585
+ `status`/`error.code`/`messages[]`, so escalate to the **client-scoped** `Logs_<Client>.Api`
586
+ for the full envelope and to `Logs.Event.context` (7-day purge) for the original payload;
587
+ (2) OPEN — `_Error::buildContext` redacts `access_token` in the decoded object but not in the
588
+ raw `rawResponse`/`lastResponse` strings, so `Event.context` holds cleartext JWTs; do not paste
589
+ those rows anywhere. (ajean)
590
+
591
+ - 2026-08-06 — Recorded an **open defect**: error capture is entirely dead on sandbox-dev/beta
592
+ because `Logs.Issue` there lacks the **`clickupPriority`** column, so the capture INSERT fails with
593
+ MySQL 1054 and **no** Issue row, Event row, or ClickUp task is written — the only trace is
594
+ `identifiers.captureFailure` inline in the API response. Cause: `_underscore` commit `4ebe12fe`
595
+ ("Improve error fingerprinting, add ClickUp priority tracking") shipped without its schema
596
+ migration reaching that environment; needs a one-column `dbchanges2` `Logs` migration. Generalized
597
+ the lesson — this subsystem cannot report its own failure, so `Logs` schema drift silently deletes
598
+ all observability for a whole tier rather than degrading. Found while debugging TRUE-80282 on beta.
599
+ (mhammontree)
542
600
  - 2026-08-05 — **Data model:** `Issue` gained an Issue-level **OPEN → RESOLVED** lifecycle
543
601
  (`status`, `dtAutoResolved`) plus a durable firing baseline (`baselineGapSeconds`,
544
602
  `baselineSampleCount`, `dtBaselineAnchor`, `baselineAnchorOccurrences`), migration
@@ -6,12 +6,16 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-28
10
- owners: ["jcardinal"]
9
+ updated: 2026-08-06
10
+ owners: ["jcardinal", "mhammontree"]
11
11
  files:
12
12
  - _underscore/Model/Core/Model.php
13
+ - _underscore/Model.php
14
+ - _underscore/Model/Rate/Subscription.php
13
15
  related:
14
16
  - ../../api2/features/language-translation-layer.md
17
+ - ./model-save-vs-query-atomic-update.md
18
+ - ../../../../clients/rate/features/subscription-cancellation.md
15
19
  ---
16
20
 
17
21
  ## Summary
@@ -41,6 +45,20 @@ if (array_key_exists('uuid', $model->getFieldConfig())) {
41
45
  Check `array_key_exists('field', $model->getFieldConfig())` first, then read via `__get`. Do **not**
42
46
  gate on `isset($model->field)` or `$model->field ?? $default` — both silently misbehave.
43
47
 
48
+ **Safe pattern for "does this field have a value?" (the emptiness test):** cast, then test the
49
+ cast — never `empty()`/`isset()` on the property itself.
50
+
51
+ ```php
52
+ // WRONG — $isRequested is permanently false even when the column holds a date
53
+ $isRequested = !empty($subscription->dtRequestToCancel);
54
+
55
+ // CORRECT — read through __get, cast to string, test the string
56
+ $isRequested = trim((string) $subscription->dtRequestToCancel) !== '';
57
+ ```
58
+
59
+ `_Model` declares `__get`/`__set` only (`_underscore/Model.php:464,481`) — there is no `__isset` on
60
+ the base `_Model` either, so this applies to **every** 2.0 model, not just the Core one.
61
+
44
62
  ## Gotchas / known issues
45
63
 
46
64
  - **Treat `empty()` / `isset()` / `??` on a magic field as simply UNRELIABLE — the exact behavior
@@ -51,6 +69,19 @@ gate on `isset($model->field)` or `$model->field ?? $default` — both silently
51
69
  value into a plain local variable first and test that local.** This cost three debugging rounds
52
70
  because it failed in the "looks unauthenticated" direction — a populated identity read as empty.
53
71
  A codebase-wide sweep for `empty(`/`isset(` on model fields is warranted.
72
+ - **⚠ An `empty()` guard on a magic field can silently break IDEMPOTENCY.** Verified empirically
73
+ 2026-08-06 on `_Model_Rate_Subscription`: `$s->dateCancelled` returns `'2026-08-26'` while
74
+ `empty($s->dateCancelled)` returns **TRUE** and `isset()` returns **FALSE** on the very same
75
+ read. The Rate cancellation idempotency guard was written as `empty($s->dtRequestToCancel)`, so
76
+ `$isRequested` was **permanently false**: an already-cancelled subscription never short-circuited,
77
+ every button click **re-called PayPal**, and the crash-recovery branch that was supposed to
78
+ *preserve* an earlier attempt's recorded intent **wiped it instead**. A magic-field `empty()` is
79
+ not a cosmetic smell — it makes "already done?" checks structurally impossible.
80
+ - **This is the sibling of the `save()` field-diff trap.** Both are the same underlying hazard: the
81
+ magic accessor layer makes correct-*looking* code silently wrong, in the direction of "nothing is
82
+ there." When you touch a 2.0 model, distrust both **emptiness tests** (this doc) and
83
+ **rollback-by-re-save** (see
84
+ [_Model::save() vs raw _Query](./model-save-vs-query-atomic-update.md)).
54
85
  - `isset($model->magicField)` / `$model->magicField ?? null` cannot be trusted for DB-backed
55
86
  magic fields. Use `getFieldConfig()` + `array_key_exists` to test presence.
56
87
  - A bare `__get` on an unconfigured field **throws** — never read a maybe-absent field without the
@@ -60,6 +91,15 @@ gate on `isset($model->field)` or `$model->field ?? $default` — both silently
60
91
 
61
92
  ## Change history
62
93
 
94
+ - 2026-08-06 — TRUE-80282 testing: confirmed the gap exists on the **base `_Model`** too (it
95
+ declares `__get`/`__set` only, `_underscore/Model.php:464,481`), so this applies to every 2.0
96
+ model. Recorded the sharpest empirical case yet: `$s->dateCancelled` returns `'2026-08-26'` while
97
+ `empty()` returns TRUE and `isset()` FALSE on the same read. It cost a real bug — the Rate cancel
98
+ **idempotency guard** used `empty()`, so an already-cancelled subscription never short-circuited,
99
+ every click re-called PayPal, and the crash-recovery branch wiped the earlier attempt's intent
100
+ instead of preserving it. Added the canonical emptiness test: `trim((string) $field) !== ''`. Also
101
+ linked this explicitly as the sibling of the `save()` field-diff trap — both are the magic layer
102
+ making correct-looking code silently wrong toward "nothing is there." (mhammontree)
63
103
  - 2026-07-28 — Broadened the rule after the cross-client work: `empty()` can report a **populated**
64
104
  field as absent while `??` reads it correctly — the inverse of the 2026-06-25 symptom. The safe
65
105
  rule is now "copy to a plain local, then test the local," and a codebase-wide sweep is warranted
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-05
10
- owners: ["jcardinal", "mhammontree", "dfranks", "apeterson", "bala"]
9
+ updated: 2026-08-06
10
+ owners: ["jcardinal", "mhammontree", "dfranks", "apeterson", "bala", "tcox"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - _underscore/Model/Client/AdvanceShippingNoticeItemUnit.php
@@ -177,9 +177,29 @@ returnTrackingNumber: {...} }]`, and an IFIU's as `itemFulfillmentItemUnitTracki
177
177
  `toga25-supply/sync_compasscanada_schema.sql` against `Client_CompassCanada` (local-only — prod
178
178
  already has the tables). See the
179
179
  [local-DB-refresh-from-beta workflow](../workflows/local-db-refresh-from-beta.md).
180
+ - **⚠ Clients that were not hand-migrated still carry `TableViewJoins` pointing at the DELETED
181
+ RecordFields — and nothing validates them until a user opens the view.** The per-client TableView
182
+ repointing was done one client at a time (Compass, NYCHH, Quad); a client onboarded before it, or
183
+ skipped, keeps joins on dropped ids and its views **500** — first
184
+ `table-views/meta` with *"Model build for primary key id '1431' for `_Model_Core_RecordField` … 0
185
+ rows"*, then the data query with *"Unknown column … in 'on clause'"* once the missing bridge join
186
+ record leaves the alias undefined. **`Client_Elite` has 11 such references across 9 views, in
187
+ production as well as sandbox** — see
188
+ [Elite supply2 TableView config drift](../../../clients/elite/features/supply2-tableview-config-drift.md)
189
+ for the full map and the NYCHH-mirroring fix. Before switching on a supply surface for a client
190
+ onboarded before 2026-06, diff its `TableViewJoins` against a migrated client. Note the same
191
+ audit found `sales-order-shipments` (join 10) referencing dead RecordField **211 in
192
+ `Client_Nychh` too** — that view is broken platform-wide and needs a backend decision, not a
193
+ copy.
180
194
  - **Porting the item-fulfillment TableView fix to another client is two independent decisions, not a copy-paste.** The **re-root** (Units 31 → ItemFulfillmentItems 29 + OUTER unit chain) is universally portable and fixes the "0 records" bug everywhere. The **tracking source is client-specific**: Compass writes one tracking number per item fulfillment at the **item level (318)**, so its views read 318. Other clients populate different bridge levels — verify with `SELECT COUNT(*)` per bridge before choosing. As of 2026-06-19, **record 318 is empty in `Client_Nychh` and `Client_Quad`**; their tracking lives at the **IF/shipment level (317)** (covers IFIs: NYCHH 7955/10049 ≈ 79%, Quad 4878/4967 ≈ 98%) and the **unit level (319)** (≈ 7–9% — serialized only). Copying Compass's 318 join into a client that doesn't write 318 yields a structurally-correct view with permanently blank tracking until 318 is backfilled. (NYCHH's pre-fix view pointed at the now-deleted record 41 = old IF-level/package bridge, i.e. it originally intended IF-level 317.)
181
195
 
182
196
  ## Change history
197
+ - 2026-08-06 — Recorded that the per-client TableView repointing left **unmigrated clients broken**:
198
+ `Client_Elite` still has 11 references to deleted RecordFields (211/321/932/358/1431) across 9
199
+ views in **prod and sandbox**, so its views 500 on `table-views/meta` and then on the data query
200
+ once the bridge join record (319 / 289) is missing. Added the pre-go-live check (diff a new
201
+ client's `TableViewJoins` against a migrated one) and noted `sales-order-shipments` join 10
202
+ references dead 211 in `Client_Nychh` as well. (tcox)
183
203
  - 2026-08-05 — Recorded that the 1.0 `App_Api_Toga2::syncItemFulfillmentFromNetsuite` full reconcile
184
204
  now maintains **all three** IF bridges (317/318/319) and, when pruning units/items NetSuite dropped,
185
205
  **deletes their bridge links first** because the bridge parent FKs are `RESTRICT` (no cascade) —