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.
- package/knowledge/1.0/apps/library/features/startech-pcmaticb2b-sync.md +37 -15
- package/knowledge/1.0/apps/library/features/toga2-api-client-and-bridge.md +9 -1
- package/knowledge/1.0/standards/backend-php.md +40 -29
- package/knowledge/1.0/standards/framework-rules.md +9 -5
- package/knowledge/2.0/apps/_underscore/INDEX.md +2 -1
- package/knowledge/2.0/apps/_underscore/features/acl-permission-chain.md +62 -0
- package/knowledge/2.0/apps/_underscore/features/config-group-access.md +95 -0
- package/knowledge/2.0/apps/_underscore/features/error-reporting-issue-event.md +60 -2
- package/knowledge/2.0/apps/_underscore/features/model-magic-field-access.md +42 -2
- package/knowledge/2.0/apps/_underscore/features/tracking-number-bridges.md +22 -2
- package/knowledge/2.0/apps/_underscore/features/units-for-items-for-purchase-orders.md +17 -3
- package/knowledge/2.0/apps/_underscore/workflows/local-db-refresh-from-beta.md +15 -2
- package/knowledge/2.0/apps/api2/INDEX.md +1 -1
- package/knowledge/2.0/apps/api2/features/environment-variable-drives-underscore-branch.md +47 -2
- package/knowledge/2.0/apps/api2/features/record-scripts.md +49 -12
- package/knowledge/2.0/apps/api2/features/v2-api-error-codes.md +57 -2
- package/knowledge/2.0/apps/toga2-commerce/INDEX.md +1 -1
- package/knowledge/2.0/apps/toga2-commerce/features/client-fields.md +42 -2
- package/knowledge/2.0/apps/toga2-supply/INDEX.md +1 -1
- package/knowledge/2.0/apps/toga2-supply/workflows/client-host-scoping.md +82 -5
- package/knowledge/2.0/apps/toga2-view/INDEX.md +1 -1
- package/knowledge/2.0/apps/toga2-view/features/get-support-cancel-subscription.md +22 -1
- package/knowledge/2.0/apps/worker2/features/creating-worker-actions.md +11 -0
- package/knowledge/2.0/standards/backend-testing.md +80 -1
- package/knowledge/INDEX.md +4 -4
- package/knowledge/clients/compass-canada/profile.md +15 -1
- package/knowledge/clients/compass-usa/INDEX.md +3 -0
- package/knowledge/clients/compass-usa/features/persona-model-and-levy-gating.md +132 -0
- package/knowledge/clients/compass-usa/profile.md +17 -1
- package/knowledge/clients/compass-usa/workflows/persona-population-env-comparison.md +82 -0
- package/knowledge/clients/compass-usa/workflows/persona-refactor-migration.md +138 -0
- package/knowledge/clients/elite/INDEX.md +2 -1
- package/knowledge/clients/elite/features/supply2-scope.md +65 -23
- package/knowledge/clients/elite/features/supply2-tableview-config-drift.md +107 -0
- package/knowledge/clients/elite/profile.md +19 -2
- package/knowledge/clients/nycdoe/features/servicenow-integration.md +75 -1
- package/knowledge/clients/quad/INDEX.md +1 -0
- package/knowledge/clients/quad/features/po-asn-email-import.md +177 -0
- package/knowledge/clients/quad/profile.md +16 -2
- package/knowledge/clients/rate/INDEX.md +2 -2
- package/knowledge/clients/rate/features/aig-contract-creation.md +53 -1
- package/knowledge/clients/rate/features/paypal-subscription-purchase-webhook.md +117 -18
- package/knowledge/clients/rate/features/subscription-cancellation.md +162 -3
- package/knowledge/sessions/2026-08-06-TRUE-80282-cancel-subscription-mhammontree.md +91 -0
- package/knowledge/sessions/2026-08-06-elite-inventory-fe-tcox.md +80 -0
- package/knowledge/sessions/2026-08-06-quad-asn-import-jam-ajean.md +256 -0
- package/knowledge/standalone/apps/claude/workflows/mcp-tool-usage.md +12 -2
- 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-
|
|
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
|
-
|
|
98
|
-
- 2026-
|
|
99
|
-
|
|
100
|
-
`
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
the
|
|
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-
|
|
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-
|
|
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
|
-
###
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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) —
|