toga-ai 1.0.535 → 1.0.536

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.
@@ -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"]
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
@@ -539,6 +563,15 @@ clientUserId). **Neither was built.** As built instead:
539
563
 
540
564
  ## Change history
541
565
 
566
+ - 2026-08-06 — Recorded an **open defect**: error capture is entirely dead on sandbox-dev/beta
567
+ because `Logs.Issue` there lacks the **`clickupPriority`** column, so the capture INSERT fails with
568
+ MySQL 1054 and **no** Issue row, Event row, or ClickUp task is written — the only trace is
569
+ `identifiers.captureFailure` inline in the API response. Cause: `_underscore` commit `4ebe12fe`
570
+ ("Improve error fingerprinting, add ClickUp priority tracking") shipped without its schema
571
+ migration reaching that environment; needs a one-column `dbchanges2` `Logs` migration. Generalized
572
+ the lesson — this subsystem cannot report its own failure, so `Logs` schema drift silently deletes
573
+ all observability for a whole tier rather than degrading. Found while debugging TRUE-80282 on beta.
574
+ (mhammontree)
542
575
  - 2026-08-05 — **Data model:** `Issue` gained an Issue-level **OPEN → RESOLVED** lifecycle
543
576
  (`status`, `dtAutoResolved`) plus a durable firing baseline (`baselineGapSeconds`,
544
577
  `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,7 +6,7 @@
6
6
  | [API Payload Interceptors (metadata-registered prePost/postPut model hooks)](features/api-payload-interceptors.md) | A `_Model`'s `prePost()` / `postPost()` / `prePut()` / `postPut()` hooks are **not** called by the model. | api2/Component/Api/V2/V2.php, _underscore/Model/Client/ItemFulfillment.php, dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql |
7
7
  | [Multi-Client (Cross-Client) Data Retrieval](features/cross-client-data-retrieval.md) | A single authenticated V2 GET listing can return records across **many** clients (designed for 1000+) that the caller is entitled to, honoring **each target cli | api2/Component/Api/CrossClient/CrossClient.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Cache/Table.php, _underscore/Model/Cache/Tables/Client.php, _underscore/Model/Core/Record.php, worker2/Worker/Platform/Cache.php, worker2/Controller/Index.php, worker2/_.php, dbchanges2/Cache/2026-06-30a - MultiClientCacheTables.sql, dbchanges2/Core/2026-06-30b - CacheClusterRegistrationAndRecordTtl.sql, dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql |
8
8
  | [Encrypted-User-UUID Auth Handoff (/auth/encrypted-user-uuid)](features/encrypted-user-uuid-auth-handoff.md) | `POST /auth/encrypted-user-uuid` is the intended **cross-client / SSO-handoff identity mechanism**: given an encrypted `{client, user}` UUID pair, it mints a fr | api2/Component/Api/CrossClient/CrossClient.php |
9
- | [ENVIRONMENT (not the EB environment name) decides the _underscore branch and Config file](features/environment-variable-drives-underscore-branch.md) | An api2 Elastic Beanstalk instance decides **which `_underscore` branch it clones** and **which `Config/<env>.ini` it loads** from the EB environment property * | api2/.ebextensions/git.php, api2/.ebextensions/php_include_underscore.config, api2/Config/beta.ini, api2/Config/sandbox-dev.ini |
9
+ | [ENVIRONMENT (not the EB environment name) decides the _underscore branch and Config file](features/environment-variable-drives-underscore-branch.md) | An api2 Elastic Beanstalk instance decides **which `_underscore` branch it clones** and **which `Config/<env>.ini` it loads** from the EB environment property * | api2/.ebextensions/git.php, api2/.ebextensions/php_include_underscore.config, api2/.ebextensions/git.sandbox-dev.json, api2/Config/beta.ini, api2/Config/sandbox-dev.ini, api2/Component/Api/V2/V2.php |
10
10
  | [Health-check endpoint (/health liveness short-circuit)](features/health-check-endpoint.md) | `_Controller_Index::api()` short-circuits **liveness/health-probe** requests to an HTTP 200 **before** any routing, DB bootstrap, or V2 engine work runs. | api2/Controller/Index.php |
11
11
  | [Language Translation Layer (audience.language + sidecar tables)](features/language-translation-layer.md) | Serves the same TOGa data (Item title/description/longDescription, plus item **feature** text — `Features.name`, `ItemCategoryFeatureGroups.name`, `ItemFeatures | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, _underscore/Model/Core/Setting.php, _underscore/Model/Core/RecordField.php, _underscore/Model/Core/DefaultGlobalSetting.php, _underscore/Model/Client/ItemTranslation.php, _underscore/Model/Client/FeatureTranslation.php, _underscore/Model/Client/ItemCategoryFeatureGroupTranslation.php, _underscore/Model/Client/ItemFeatureTranslation.php, dbchanges2/Client/2026-06-23a - ItemTranslations.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-13a - FeatureTranslations.sql, dbchanges2/Client/2026-07-13b - FeatureTranslationsAcl.sql, dbchanges2/Core/2026-06-23a - RecordFieldsTranslationColumn.sql, dbchanges2/Core/2026-06-23b - ItemTranslationsRecord.sql, dbchanges2/Core/2026-07-13 - FeatureTranslationsRecord.sql |
12
12
  | [Nested FK object embedding is gated by the CHILD record's own ACL](features/nested-fk-acl-embedding.md) | When the V2 JSON engine serializes a foreign-key field into a **nested object** (in `getFullModelData()`, ~V2.php L6016-6060), it re-checks the **child** record | api2/Component/Api/V2/V2.php, dbchanges2/Client_Compass/2026-07-23b - PurchaseOrdersRecordReadAcl.sql |
@@ -6,13 +6,15 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-05
10
- owners: ["bala"]
9
+ updated: 2026-08-06
10
+ owners: ["bala", "mhammontree"]
11
11
  files:
12
12
  - api2/.ebextensions/git.php
13
13
  - api2/.ebextensions/php_include_underscore.config
14
+ - api2/.ebextensions/git.sandbox-dev.json
14
15
  - api2/Config/beta.ini
15
16
  - api2/Config/sandbox-dev.ini
17
+ - api2/Component/Api/V2/V2.php
16
18
  related:
17
19
  - ../architecture.md
18
20
  - ../workflows/environment-configuration-and-provisioning.md
@@ -65,6 +67,38 @@ Never infer the environment from:
65
67
  point at the same cluster, `dev.sandbox.database.togahub.com`**, so querying "the dev-sandbox
66
68
  database" and finding your row proves nothing about which tier wrote it.
67
69
 
70
+ ## `api.beta.togahub.com` — the concrete mapping, and the leftover files that mislead
71
+
72
+ Independently re-confirmed 2026-08-06 (cost **two deploy cycles**):
73
+
74
+ - `api.beta.togahub.com` runs **`ENVIRONMENT=beta`** → reads `Config/beta.ini` → clones
75
+ `_underscore` branch **`_beta`**.
76
+ - There is **deliberately NO `.ebextensions/git.beta.json`**, which is exactly why the branch falls
77
+ through to the derived `'_' . strtolower($env)` = `_beta`.
78
+ - **`Config/sandbox-dev.ini` AND `.ebextensions/git.sandbox-dev.json` both still exist** and the
79
+ latter **pins `branch: _sandbox-dev`** — apparently left behind by a rename. They point at the
80
+ same host and they are **not what serves it.** They are the single most misleading pair of files
81
+ in the repo: they look authoritative and they are dead.
82
+ - **Consequence: merging `_underscore` to `_sandbox-dev` does NOT reach that environment.**
83
+
84
+ ### ⚠ Diagnostic: `Call to undefined method _Model_Client_<X>::<method>()` means the CLASS ISN'T DEPLOYED
85
+
86
+ When a client model override is missing from the deployed `_underscore`, V2 does **not** error at
87
+ resolution time. `class_exists` fails, so V2 quietly **declines the override and falls back to the
88
+ BASE model** (`V2.php:3155-3162`). The base model has no client-specific method, so you get:
89
+
90
+ ```
91
+ Call to undefined method _Model_Client_Subscription::cancelApi()
92
+ ```
93
+
94
+ **Read this error as "the override class is not on the server," not "the code is wrong."** The
95
+ `_Model_Client_*` (base) prefix in the message — where you expected `_Model_<Slug>_*` — is the tell.
96
+ Confirm by file presence on the box, then check which branch that tier actually clones (above).
97
+
98
+ Related detail: **the slug V2 uses to pick the override class is `Clients.clientIdentifier`**
99
+ (`V2.php:1521`), taken from the JWT. `Core.Clients` has **no `slug` column** — do not go looking for
100
+ one.
101
+
68
102
  ## Gotchas / known issues
69
103
 
70
104
  - **⚠ Merging to the branch named after the EB environment can be a no-op.** Merge to
@@ -88,6 +122,17 @@ Never infer the environment from:
88
122
 
89
123
  ## Change history
90
124
 
125
+ - 2026-08-06 — TRUE-80282 deploy: independently re-confirmed the mapping from a second tier
126
+ (`api.beta.togahub.com` = `ENVIRONMENT=beta` → `Config/beta.ini` → branch `_beta`) at a cost of two
127
+ deploy cycles, and recorded **why** the branch derives: there is deliberately **no
128
+ `git.beta.json`**, while the leftover `Config/sandbox-dev.ini` + `git.sandbox-dev.json` (which
129
+ pins `branch: _sandbox-dev`) survive a rename, point at the same host, and are dead. Added the
130
+ key diagnostic: a missing client override does **not** error at resolution — `class_exists` fails
131
+ and V2 silently falls back to the BASE model (`V2.php:3155-3162`), surfacing as
132
+ **`Call to undefined method _Model_Client_<X>::<method>()`**, which means *the class is not on the
133
+ deployed server*, not that the code is wrong (the `_Model_Client_` prefix is the tell). Also
134
+ recorded that the override slug is **`Clients.clientIdentifier`** (`V2.php:1521`) and that
135
+ `Core.Clients` has no `slug` column. (mhammontree)
91
136
  - 2026-08-05 — Documented that `ENVIRONMENT` (an EB environment property), **not** the EB
92
137
  environment name, drives both the `_underscore` branch cloned by `.ebextensions/git.php`
93
138
  (`'_' . strtolower($env)`, unless `git*.json` pins `branch`) and the `Config/<env>.ini` loaded via
@@ -60,13 +60,33 @@ Rules the engine enforces (see `getRecordScriptPhpMethod()` + the dispatch in
60
60
  - **Remaining parameters are named** and are filled from the **query string**. Each query-string
61
61
  key maps to the same-named PHP parameter; unmatched named args land in a `...$args` variadic
62
62
  if the method declares one. (`transactionId` and `api` keys are reserved and never mapped.)
63
- - **The return value becomes the envelope `data` — but nested under the route segment.** The
64
- engine does `$outData[$requestedUuid] = method(...)` then `return (object)$outData`
65
- (`V2.php` ~:4121, :6305), so the script's payload lands **under the script's route-segment
66
- key**, not flat. `GET /v2/sprints/tile` yields `data: { tile: {…} }`, not `data: {…}`;
67
- `campaigns/jobs` yields `data: { jobs: … }`. **A consumer must unwrap `data[<route>]`.** The
68
- method itself still returns the raw object/array — do not echo, and never build the
63
+ - **The return value becomes the envelope `data` — nested TWICE.** The engine does
64
+ `$outData[$requestedUuid] = method(...)` then `return (object)$outData`
65
+ (`V2.php` ~:4121, :6305), and that sits under the **record route** in the envelope. So the full
66
+ path a consumer must read is:
67
+
68
+ ```
69
+ data.<recordRoute>.<scriptRoute>
70
+ ```
71
+
72
+ **and the second key is the script's `route`, not its `phpMethod`.** Concretely:
73
+
74
+ | Endpoint | route / phpMethod | Read from |
75
+ |---|---|---|
76
+ | `POST /v2/subscriptions/cancel` | `cancel` / `cancelApi` | `data.subscriptions.cancel` |
77
+ | `GET /v2/entitlements/warranty-availability` | `warranty-availability` / `warrantyAvailability` | `data.entitlements["warranty-availability"]` |
78
+ | `GET /v2/sprints/tile` | `tile` / `tile` | `data.sprints.tile` |
79
+
80
+ Working precedent to copy:
81
+ `toga2-view/src/pages/ZipValidation/api/zipValidation.ts:88` —
82
+ `response?.data?.entitlements?.["warranty-availability"]`.
83
+
84
+ The method itself still returns the raw object/array — do not echo, and never build the
69
85
  success/error envelope yourself.
86
+
87
+ > **⚠ A local test that calls the model method directly can NEVER catch a wrong unwrap**, because
88
+ > it never traverses the envelope. This is the one class of defect that requires an over-the-wire
89
+ > call to detect. Budget for it: see the gotcha below for what it cost.
70
90
  - Invocation is effectively `$model::$phpMethod(...$args)`.
71
91
 
72
92
  ### ⚠ Argument binding is PHP NAMED-ARGUMENT unpacking of the QUERY STRING — with two consequences
@@ -261,12 +281,20 @@ an `AclRecordScripts` grant in each client DB.
261
281
  ([cancellation doc](../../../../clients/rate/features/subscription-cancellation.md)).
262
282
  - **A scripted POST skips the record-level ACL check** — see the section above. Do not "fix" a
263
283
  scripted-POST 403 with an `AclRecordPermissions` CREATE grant.
264
- - **The response key is the ROUTE VERBATIM, not the `phpMethod`.** A script registered with
265
- route `warranty-availability` and `phpMethod` `warrantyAvailability` returns
266
- **`data.entitlements["warranty-availability"]`** — *not* `data.entitlements.warrantyAvailability`.
267
- The sibling `addresses/validateAddress` reader looks camelCase only because **that script's
268
- route is itself `validateAddress`.** Reading the wrong key yields `undefined` (and, in a
269
- fail-closed consumer, correctly denies) — this cost a debug cycle in beta.
284
+ - **The response is nested TWICE (`data.<recordRoute>.<scriptRoute>`) and the key is the ROUTE
285
+ VERBATIM, not the `phpMethod`.** A script registered with route `warranty-availability` and
286
+ `phpMethod` `warrantyAvailability` returns **`data.entitlements["warranty-availability"]`** —
287
+ *not* `data.entitlements.warrantyAvailability`. The sibling `addresses/validateAddress` reader
288
+ looks camelCase only because **that script's route is itself `validateAddress`.**
289
+ - **⚠ Reading the wrong key is the most dangerous shape of bug this contract produces, because it
290
+ fails AFTER the side effect.** In a fail-closed *read* consumer an `undefined` correctly denies
291
+ (one debug cycle in beta, 2026-07-31). But on a **write/action** endpoint the backend has already
292
+ committed. Concrete case 2026-08-06: `POST /v2/subscriptions/cancel` was read as `data.cancel`
293
+ instead of `data.subscriptions.cancel`, so the result was `undefined`, the frontend threw, and the
294
+ customer was told **the cancellation FAILED** — while PayPal had already been cancelled and the
295
+ correct state written. That mismatch (user believes it failed, system knows it succeeded) is the
296
+ worst possible outcome: it invites a retry against a completed irreversible action. **Verify the
297
+ unwrap path with a real HTTP call before shipping any action endpoint.**
270
298
  - **No bound params in `_Query`.** Sanitize with `\_Database::escape()` (strings) and `(int)`
271
299
  casts (numerics) for every value placed into the query text — query-string args are user
272
300
  input.
@@ -287,6 +315,15 @@ an `AclRecordScripts` grant in each client DB.
287
315
 
288
316
  ## Change history
289
317
 
318
+ - 2026-08-06 — TRUE-80282 testing: sharpened the envelope contract from ambiguous ("nested under the
319
+ route segment") to the explicit **two-level `data.<recordRoute>.<scriptRoute>`**, with a
320
+ route-vs-`phpMethod` table and the working precedent
321
+ (`toga2-view/.../zipValidation.ts:88`). Recorded that reading the wrong key is **most dangerous on
322
+ an action endpoint because it fails after the side effect**: reading `data.cancel` instead of
323
+ `data.subscriptions.cancel` told the customer the cancellation **failed** while PayPal was already
324
+ cancelled and correct state written — inviting a retry against a completed irreversible action.
325
+ Also recorded that a **local test calling the model method directly can never catch a wrong
326
+ unwrap**, so an over-the-wire call is mandatory before shipping an action endpoint. (mhammontree)
290
327
  - 2026-08-04 — TRUE-80282: documented that a **scripted POST bypasses the record-level
291
328
  `AclRecordPermissions` check entirely** (`V2.php:4472` guards it with
292
329
  `if (!$isUsingScriptedCall && empty($routePairs))`), so authorization rests solely on the script
@@ -3,7 +3,7 @@
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
5
  | [TOGa View Frontend (toga2-view) Architecture](architecture.md) | `toga2-view` is the **React/TypeScript single-page frontend** for the TOGa 2.0 platform — the customer-facing web app (home warranty / tech-support portals). | toga2-view/tsconfig.json, toga2-view/tsconfig.app.json, toga2-view/src/main.tsx, toga2-view/src/App.tsx, toga2-view/src/routes.tsx, toga2-view/src/api/axiosInstance.ts, toga2-view/src/api/apiFunctions.ts, toga2-view/src/utils/queryHelpers.ts, toga2-view/src/contexts/AuthContext.tsx, toga2-view/src/contexts/useUserStore.ts, toga2-view/src/hooks/useAuthenticationFlow.ts, toga2-view/vite.config.ts, toga2-view/package.json |
6
- | [Get Support — Subscription Details & Cancel Subscription Gating](features/get-support-cancel-subscription.md) | The Get Support page (`/get-support?itemUuid=…`) shows a **subscription details card** (plan · price · renewal date) for the service the user clicked, and — **o | toga2-view/src/pages/GetSupport/viewModels/getSupportPageViewModel.ts, toga2-view/src/pages/GetSupport/view/GetSupportPage.tsx, toga2-view/src/pages/GetSupport/viewModels/DUMMYFIELDS/SUPPORTDUMMYFIELDS.json, toga2-view/src/constants/featureFlags.ts, toga2-view/src/hooks/useBundleServices.ts |
6
+ | [Get Support — Subscription Details & Cancel Subscription Gating](features/get-support-cancel-subscription.md) | The Get Support page (`/get-support?itemUuid=…`) shows a **subscription details card** (plan · price · renewal date) for the service the user clicked, and — **o | toga2-view/src/pages/GetSupport/viewModels/getSupportPageViewModel.ts, toga2-view/src/pages/GetSupport/api/getSupport.ts, toga2-view/src/pages/GetSupport/view/GetSupportPage.tsx, toga2-view/src/pages/GetSupport/viewModels/DUMMYFIELDS/SUPPORTDUMMYFIELDS.json, toga2-view/src/constants/featureFlags.ts, toga2-view/src/hooks/useBundleServices.ts |
7
7
  | [Mobile Nav Header](features/mobile-nav-header.md) | The `MobileNavToggle` component renders the fixed 72 px header (hamburger + Rate logo) and the slide-in drawer nav. | toga2-view/src/components/MobileNav/MobileNav.tsx, toga2-view/public/assets/Rate_Logo.svg |
8
8
  | [/v2 Query-String Builder (assembleOptions / where coercion)](features/query-string-builder.md) | `src/utils/queryHelpers.ts` converts a structured JS options object (`fields`, `where`, `join`/`ojoin`, `sort`, …) into the `api2` `/v2` query string. | toga2-view/src/utils/queryHelpers.ts, toga2-view/src/utils/queryHelpers.test.ts |
9
9
  | [Service Card Component](features/service-card.md) | The `ServiceCard` component renders a single service subscription (tech support or home warranty) on both the Home and Services pages. | toga2-view/src/components/ServiceCard/ServiceCard.tsx, toga2-view/src/pages/Services/view/ServicesPage.tsx, toga2-view/src/pages/Home/view/HomePage.tsx, toga2-view/src/constants/bundleConstants.ts, toga2-view/src/pages/Services/viewModels/DUMMYFIELDS/SERVICESDUMMYFIELDS.json |
@@ -6,10 +6,11 @@ project: TOGa View Frontend
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-04
9
+ updated: 2026-08-06
10
10
  owners: ["tcox", "mhammontree"]
11
11
  files:
12
12
  - toga2-view/src/pages/GetSupport/viewModels/getSupportPageViewModel.ts
13
+ - toga2-view/src/pages/GetSupport/api/getSupport.ts
13
14
  - toga2-view/src/pages/GetSupport/view/GetSupportPage.tsx
14
15
  - toga2-view/src/pages/GetSupport/viewModels/DUMMYFIELDS/SUPPORTDUMMYFIELDS.json
15
16
  - toga2-view/src/constants/featureFlags.ts
@@ -140,6 +141,18 @@ not self-service cancellable from the portal; only tech-support subscriptions ar
140
141
  details card** — that is the intended kill-switch behavior.
141
142
  - **Never close the confirm modal on failure.** Closing it reads to the user as a successful
142
143
  cancellation.
144
+ - **⚠ Unwrap the scripted response as `data.subscriptions.cancel` — nested TWICE, keyed by the
145
+ script's ROUTE (`cancel`), not its phpMethod (`cancelApi`).** Reading `data.cancel` yields
146
+ `undefined`, the view model throws, and the user is told **the cancellation FAILED — while the
147
+ backend has already cancelled at PayPal and written correct state.** On an irreversible action
148
+ that is the worst possible mismatch, because the user's natural response is to retry. This
149
+ actually shipped to beta. Copy the working precedent:
150
+ `src/pages/ZipValidation/api/zipValidation.ts:88`
151
+ (`response?.data?.entitlements?.["warranty-availability"]`). Contract:
152
+ [record scripts](../../api2/features/record-scripts.md).
153
+ - **A failure message here does NOT mean nothing happened.** Because the PayPal cancel precedes the
154
+ response, any error surfaced after the request was accepted may sit on top of a **completed**
155
+ cancellation. Never present a failure as "nothing was changed," and never auto-retry the cancel.
143
156
 
144
157
  ## Client variations
145
158
 
@@ -151,6 +164,14 @@ same user, so the page must distinguish them per service rather than per account
151
164
 
152
165
  ## Change history
153
166
 
167
+ - 2026-08-06 — TRUE-80282 testing: **UI verified on beta** — with a pending cancellation the card
168
+ renders "Service ends by Aug 27, 2026" and both the Cancel button and the "cancel anytime" note
169
+ disappear, while the subscription stays active. Fixed a shipped bug: the response was read as
170
+ `data.cancel` instead of **`data.subscriptions.cancel`** (the scripted envelope nests twice and is
171
+ keyed by the script **route**, not the phpMethod), so the result was `undefined`, the view model
172
+ threw, and the customer was told the cancellation **failed** even though PayPal had already been
173
+ cancelled and correct state written. Recorded the general rule that a failure message on this
174
+ action never implies nothing happened, and that the cancel must not be auto-retried. (mhammontree)
154
175
  - 2026-08-04 — TRUE-80282: **the backend shipped**, so `cancelSubscription()` is no longer a
155
176
  rejecting stub — it calls `POST /v2/subscriptions/cancel` with `{ entitlementUuid }` in the body.
156
177
  Recorded that the `serviceType === 'tech'` gate is a UX affordance only (the server refuses
@@ -5,12 +5,14 @@ project: _Underscore
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-07-08
8
+ updated: 2026-08-06
9
9
  owners: [mhammontree]
10
10
  files:
11
11
  - _underscore/Query.php
12
12
  - _underscore/Database.php
13
13
  - _underscore/Environment.php
14
+ - _underscore/Model.php
15
+ - test/@Mark/Rate/verify_cancel_subscription.php
14
16
  related:
15
17
  - ../apps/_underscore/architecture.md
16
18
  - ../apps/_underscore/features/per-client-database-connections.md
@@ -94,6 +96,69 @@ This is the same lazy-transaction write-drop family noted in the `_underscore` a
94
96
  it bites the standalone-script/verification case specifically. Demonstrated working in
95
97
  `test/@Mark/Rate/verify_wholehome_per_address_guard.php`.
96
98
 
99
+ ## Auto-detect the 2.0 Core database — NEVER default to `Core`
100
+
101
+ **Locally the 2.0 Core schema is `Core_2`; the schema literally named `Core` is the 1.0 database.**
102
+ So a `?: 'Core'` fallback does not fail — it silently registers the **1.0** DB, and every Core
103
+ metadata lookup then quietly returns nothing. Nothing errors; your assertions just see empty
104
+ results and you debug the wrong layer.
105
+
106
+ Probe for the schema that actually has the 2.0 metadata tables, and **assert the result** rather
107
+ than falling back:
108
+
109
+ ```php
110
+ // Probe candidates for the 2.0 Core metadata tables, then ASSERT — never `?: 'Core'`.
111
+ $coreDb = null;
112
+ foreach (['Core_2', 'Core'] as $candidate) {
113
+ $row = (new \_Query("
114
+ SELECT COUNT(*) AS n
115
+ FROM information_schema.TABLES
116
+ WHERE
117
+ TABLE_SCHEMA = '" . \_Database::escape($candidate) . "' AND
118
+ TABLE_NAME IN ('Records', 'RecordFields')
119
+ "))->fetchRow();
120
+ if ((int) $row->n === 2) { $coreDb = $candidate; break; }
121
+ }
122
+ if ($coreDb === null) {
123
+ throw new \RuntimeException('Could not resolve the 2.0 Core schema (need Records + RecordFields).');
124
+ }
125
+ ```
126
+
127
+ `Records` + `RecordFields` are the right discriminator: the 1.0 `Core` has neither.
128
+
129
+ > **Known latent bug:** `test/@Mark/Rate/verify_wholehome_per_address_guard.php` still uses the
130
+ > `?: 'Core'` fallback. It is harmless **only** because nothing in it reads Core metadata — fix it
131
+ > before adding any assertion that does.
132
+
133
+ ## A raw SQL `UPDATE` then a model reload returns a STALE row
134
+
135
+ `_Model::load()` consults **`_Database::$_modelCache`**, which is a **separate cache from the query
136
+ cache** — `useQueryCache(false)` does **not** disable it — and is flushed only by `_Model::save()`.
137
+ So the "mutate via SQL, reload the model, assert" pattern silently asserts against the pre-update
138
+ row, and a test of a *correct* fix fails (or a broken one passes).
139
+
140
+ **When testing a pure function, mutate the loaded model in memory** and pass it in; do not round-trip
141
+ through the database:
142
+
143
+ ```php
144
+ $subscription->dateEnd = '2026-01-01'; // in-memory, no cache involved
145
+ $result = _Model_Rate_Subscription::effectiveEndDate($subscription);
146
+ ```
147
+
148
+ Reach for SQL only when you are testing persistence itself — and then re-`load()` through a
149
+ **freshly constructed** model, not the cached instance.
150
+
151
+ ## Scripts that touch real data must be self-limiting
152
+
153
+ A verification script runs against a **real client database**, so build in the guards up front:
154
+ tag every seeded row with a **per-run token**, tear down in a **`finally`** block so a failed
155
+ assertion still cleans up, and **abort at startup if the host looks production-shaped.** Pattern
156
+ reference: `test/@Mark/Rate/verify_cancel_subscription.php` (34 assertions).
157
+
158
+ Also design around what a direct-call script **cannot** see: calling a model method directly never
159
+ traverses the API response envelope, so it can never catch a wrong `data.<record>.<script>` unwrap.
160
+ Any endpoint with a side effect needs one real over-the-wire call in addition to the script.
161
+
97
162
  ## Rules
98
163
 
99
164
  - Do not add or assume a PHPUnit suite for 2.0 backend work; write a `test`-repo script instead.
@@ -102,3 +167,17 @@ it bites the standalone-script/verification case specifically. Demonstrated work
102
167
  - **Commit seeded rows before you read them back** — the read connection cannot see uncommitted
103
168
  writes; commit cleanup too so it persists.
104
169
  - Keep scripts in your per-dev `test` folder; never point them at production databases.
170
+ - **Never default the Core schema to `Core`** — probe for `Records` + `RecordFields` and assert.
171
+ Locally, `Core` is the **1.0** database and every 2.0 Core lookup silently returns nothing.
172
+ - **Do not `UPDATE` via raw SQL and then reload a model to assert** — `_Database::$_modelCache` is
173
+ separate from the query cache and is flushed only by `save()`, so the reload is stale. Mutate the
174
+ model in memory when testing a pure function.
175
+
176
+ ## Change history
177
+
178
+ - 2026-08-06 — TRUE-80282: added Core-DB auto-detection (never `?: 'Core'` — locally `Core` is the
179
+ 1.0 DB, so the fallback silently registers the wrong schema; flagged the latent bug in
180
+ `verify_wholehome_per_address_guard.php`), the `_Database::$_modelCache` staleness trap (separate
181
+ from `useQueryCache(false)`, flushed only by `save()`, so raw-UPDATE-then-reload asserts a stale
182
+ row), and self-limiting-script requirements (per-run token, `finally` teardown, production-host
183
+ abort). Derived from `verify_cancel_subscription.php`. (mhammontree)
@@ -18,7 +18,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
18
18
 
19
19
  ## 2.0 framework
20
20
 
21
- - **_underscore** (_Underscore) _(framework core)_ — 46 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
+ - **_underscore** (_Underscore) _(framework core)_ — 47 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 41 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 22 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 5 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -9,6 +9,6 @@
9
9
  | [Rate SAML SSO](features/saml-sso.md) | 2.0 | Rate uses Azure AD as its IdP (`login.rate.com`). | _underscore/Model/Rate/ClientAuthentication.php, saml/Controller/Index.php, toga2-view/src/hooks/useAuthenticationFlow.ts |
10
10
  | [Service Card Entitlement Display](features/service-card-entitlements.md) | 2.0 | Rate's home and services pages display one service card per purchased entitlement. | src/components/ServiceCard/ServiceCard.tsx, src/components/ServiceCard/index.ts, src/hooks/useBundleServices.ts, src/hooks/useActiveServices.ts, src/pages/Home/api/homeApi.ts, src/pages/Home/view/HomePage.tsx, src/pages/Home/viewModels/useHomePageViewModel.ts, src/pages/Services/view/ServicesPage.tsx, src/pages/Services/viewModels/useServicePageViewModel.ts, src/api/serviceAddressApi.ts, src/api/apiErrors.ts, dbchanges2/Client_Rate/2026-07-24a - AddressIdFieldPermission.sql |
11
11
  | [Rate Service-Purchase Confirmation Emails (Tech / Warranty)](features/service-purchase-emails.md) | 2.0 | When a Rate customer purchases a service, a confirmation email is sent. | _underscore/Model/Rate/Entitlement.php, worker2/Worker/Notification/EmailTemplate.php, dbchanges2/Client_Rate/2026-06-30a - Rate purchase email templates.sql |
12
- | [Rate Tech-Support Subscription Cancellation (portal self-service)](features/subscription-cancellation.md) | 2.0 | Rate portal customers can cancel their **tech-support** subscription themselves. | _underscore/Model/Rate/Subscription.php, _underscore/Component/Api/Paypal/Paypal.php, worker2/Worker/Rate.php, dbchanges2/Client_Rate/2026-08-04a, dbchanges2/Client_Rate/2026-08-04b, dbchanges2/Core/2026-08-04a, toga2-view/src/pages/GetSupport, toga2-view/src/components/ServiceCard/ServiceCard.tsx, toga2-view/src/pages/Home/api/homeApi.ts, toga2-view/src/hooks/useBundleServices.ts, toga2-view/src/api/apiErrors.ts, api2/Config/beta.ini |
12
+ | [Rate Tech-Support Subscription Cancellation (portal self-service)](features/subscription-cancellation.md) | 2.0 | Rate portal customers can cancel their **tech-support** subscription themselves. | _underscore/Model/Rate/Subscription.php, _underscore/Component/Api/Paypal/Paypal.php, worker2/Worker/Rate.php, dbchanges2/Client_Rate/2026-08-04a, dbchanges2/Client_Rate/2026-08-04b, dbchanges2/Core/2026-08-04a, toga2-view/src/pages/GetSupport, toga2-view/src/components/ServiceCard/ServiceCard.tsx, toga2-view/src/pages/Home/api/homeApi.ts, toga2-view/src/hooks/useBundleServices.ts, toga2-view/src/api/apiErrors.ts, api2/Config/beta.ini, api2/.ebextensions/git.php, toga2-view/src/pages/GetSupport/api/getSupport.ts, toga2-view/src/pages/GetSupport/viewModels/getSupportPageViewModel.ts, test/@Mark/Rate/verify_cancel_subscription.php |
13
13
  | [Rate Whole Home Warranty Per-Address Purchase Guard](features/whole-home-warranty-purchase-guard.md) | 2.0 | > # ⚠ STATUS (2026-07-31, TRUE-80487) — THE GUARD HAD NEVER EXECUTED, IN ANY ENVIRONMENT > > Everything below the next section described a guard that **never ra | _underscore/Model/Rate/Entitlement.php, _underscore/Model/Client/Entitlement.php, _underscore/Model/Client/Address.php, _underscore/Component/Library/Carriers/Usps/Usps.php, toga2-view/src/pages/CheckOut/api/checkoutApi.ts, toga2-view/src/pages/CheckOut/viewModel/useCheckoutPageViewModel.ts, toga2-view/src/pages/ZipValidation/api/zipValidation.ts, dbchanges2/Client/2026-07-22a - EntitlementServiceAddressId.sql, dbchanges2/Core/2026-07-22a - EntitlementServiceAddressIdField.sql, dbchanges2/Client_Rate/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql, dbchanges2/Client_Rate/2026-07-31a - WarrantyAvailabilityCustomRecordScript.sql, test/@Mark/Rate/verify_wholehome_per_address_guard.php, test/@Mark/Rate/verify_wh_gate_and_unit_dedup.php |
14
14
  | [Rate](profile.md) | 2.0 | Rate is a mortgage/lending client. | |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: rate
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-04
9
+ updated: 2026-08-06
10
10
  owners: [mhammontree, tcox]
11
11
  files:
12
12
  - _underscore/Model/Rate/Subscription.php
@@ -21,6 +21,10 @@ files:
21
21
  - toga2-view/src/hooks/useBundleServices.ts
22
22
  - toga2-view/src/api/apiErrors.ts
23
23
  - api2/Config/beta.ini
24
+ - api2/.ebextensions/git.php
25
+ - toga2-view/src/pages/GetSupport/api/getSupport.ts
26
+ - toga2-view/src/pages/GetSupport/viewModels/getSupportPageViewModel.ts
27
+ - test/@Mark/Rate/verify_cancel_subscription.php
24
28
  related:
25
29
  - clients/rate/profile.md
26
30
  - service-card-entitlements.md
@@ -31,6 +35,10 @@ related:
31
35
  - ../../../2.0/apps/api2/features/record-scripts.md
32
36
  - ../../../2.0/apps/_underscore/features/model-save-vs-query-atomic-update.md
33
37
  - ../../../2.0/apps/_underscore/features/acl-permission-chain.md
38
+ - ../../../2.0/apps/_underscore/features/model-magic-field-access.md
39
+ - ../../../2.0/apps/_underscore/features/config-group-access.md
40
+ - ../../../2.0/apps/api2/features/environment-variable-drives-underscore-branch.md
41
+ - ../../../2.0/standards/backend-testing.md
34
42
  ---
35
43
 
36
44
  ## Summary
@@ -141,6 +149,48 @@ agreement.
141
149
 
142
150
  **Never place the live pair in a non-prod ini — cancellation is irreversible at PayPal.**
143
151
 
152
+ ## Verification status (2026-08-06) — what is PROVEN and what is UNPROVABLE off production
153
+
154
+ **✅ Verified end-to-end on beta (`api.beta.togahub.com`, PayPal SANDBOX).** One real cancellation
155
+ was driven all the way through:
156
+
157
+ - `POST /v2/subscriptions/cancel` → **200**, `dateCancel: 2026-08-27`.
158
+ - A real PayPal **sandbox** billing agreement was actually cancelled at PayPal.
159
+ - `Client_Rate.Subscriptions` id 84 inspected directly: **`isActive = 1`** (paid coverage
160
+ preserved — the whole point), **`dateEnd` untouched** at `2026-08-27`,
161
+ `dateCancelled = 2026-08-27`, `dtRequestToCancel` stamped.
162
+ - UI: the card renders **"Service ends by Aug 27, 2026"**, and the Cancel button plus the
163
+ "cancel anytime" note both disappear.
164
+
165
+ **❌ The entire worker2 half is UNVERIFIED and cannot be verified off production.** Non-prod
166
+ **receives no PayPal webhooks** and **has no cron scheduler**, so these remain production-only
167
+ checks, tracked under **TRUE-80575**:
168
+
169
+ - `handleSubscriptionDeactivation` (the `BILLING.SUBSCRIPTION.CANCELLED` wind-down branch)
170
+ - the webhook-side reconciliation `dateCancelled` stamp
171
+ - the `Rate/ExpireCancelledSubscriptions` cron
172
+
173
+ Do not read "verified on beta" as "verified." The half that protects the customer's paid coverage
174
+ *after* the cancel — and the half that eventually revokes it — has never run. Combined with the
175
+ known production webhook outage (see the last gotcha), treat the wind-down path as unexercised code.
176
+
177
+ ## Regression / verification script
178
+
179
+ `test/@Mark/Rate/verify_cancel_subscription.php` — **34 assertions, all passing.** Covers the
180
+ effective-date logic (including past and missing `dateEnd`), warranty-vs-tech discrimination, the
181
+ borrower → item → subscription resolution join, **every `cancelApi` pre-PayPal rejection with an
182
+ assertion that nothing was written**, the idempotent double-click return, and a NULL-rollback
183
+ regression pinning the `save()` field-diff trap.
184
+
185
+ **It never calls PayPal** — every covered path provably returns or throws before the PayPal call. It
186
+ seeds a scratch graph tagged with a per-run token, tears down in `finally`, and aborts if the host
187
+ looks production-shaped. Two reusable techniques it introduced (Core-DB autodetection and the
188
+ `_Database::$_modelCache` staleness trap) are proposed for the
189
+ [2.0 backend testing standard](../../../2.0/standards/backend-testing.md).
190
+
191
+ Note the structural limit: because it calls the model method **directly**, it cannot catch a
192
+ response-envelope unwrap error — which is exactly the bug that reached beta (see gotchas).
193
+
144
194
  ## Migration ordering is a dependency contract
145
195
 
146
196
  `dbchanges2` runs files **alphabetically within a folder**, so the same-day letter suffix is the
@@ -149,6 +199,57 @@ record-script registration (`2026-08-04b`)**. Reversed, the endpoint goes live w
149
199
  still cannot see the columns it needs — which is precisely the window in which a cancel truncates a
150
200
  customer's paid coverage.
151
201
 
202
+ **Second ordering rule, learned the hard way: the CODE must be deployed BEFORE the registration
203
+ migration runs.** If `2026-08-04b` registers the route while the deployed `_underscore` lacks
204
+ `cancelApi`, the route resolves to a phpMethod that does not exist and you get a **confusing 500**
205
+ instead of a clean "route not registered." Full order: **code → field grants (`a`) → registration
206
+ (`b`)**.
207
+
208
+ ### Grant verification (2026-08-06) — the `a` migration is a NO-OP where the grants already exist
209
+
210
+ Confirmed in **both sandbox-dev and production**: `Subscriptions.dtRequestToCancel` (RecordFields
211
+ id **1698**), `dateCancelled` (**1697**) and `dateEnd` (**1696**) are all already granted to role
212
+ `Base` with `isWritable = 1`, on `recordId 253` (route `subscriptions`). So
213
+ `dbchanges2/Client_Rate/2026-08-04a` inserts nothing in those environments — **harmless,
214
+ re-runnable, and still required** to protect a freshly-created blank client DB. The hardcoded Core
215
+ RecordFields id literals were verified correct per environment.
216
+
217
+ **The decisive link for worker2:** `Client_Rate.Apis` uuid
218
+ `fe4df2e2-f685-4c03-a2d7-2015502dadc9` — which is exactly `_Worker_Rate::API_UUID_RATE` — carries
219
+ **both the `Base` and `API` roles**. Roles union, so the worker inherits `Base`'s grants and **can
220
+ read the cancellation columns.** This is why mirroring `dateEnd`'s grants was the right call rather
221
+ than inventing a worker-specific role.
222
+
223
+ ## ⚠ SECURITY — pre-existing unscoped ACL grant undermines this feature's core guarantee
224
+
225
+ **Needs its own ticket. Found by reading the permission tables only — NOT exploited, NOT tested.**
226
+
227
+ In `Client_Rate`, role **`Base`** — which **every** portal customer carries, and which the Rate API
228
+ user also carries — has an `AclRecordPermissions` row on the `subscriptions` record (**recordId
229
+ 253**) with `allowCreate` / `allowRead` / `allowUpdate` / `allowDelete` **all = 1** and **`appId`
230
+ and `indirectRecordId` both NULL — i.e. no scoping whatsoever.** Combined with
231
+ `AclFieldPermissions.isWritable = 1` on `isActive`, `dateEnd`, `dateCancelled`, `dtRequestToCancel`
232
+ and `token`. **Identical in sandbox-dev AND production.**
233
+
234
+ Per the ACL data, this permits a logged-in customer to `PUT /v2/subscriptions/{uuid}` and:
235
+
236
+ - extend their own **`dateEnd`** indefinitely → **free coverage**;
237
+ - clear **`dateCancelled`** → un-cancel;
238
+ - overwrite the PayPal agreement **`token`**;
239
+ - **DELETE** a subscription row;
240
+ - and with **no row scoping**, potentially operate on **another customer's** subscription.
241
+
242
+ **This directly undermines TRUE-80282's guarantee.** `cancelApi` computes the effective end date
243
+ **server-side precisely so a customer cannot extend their own coverage** — but ordinary CRUD can
244
+ write the same column, bypassing the script entirely. A scripted endpoint's server-side computation
245
+ is only as strong as the CRUD permissions on the columns it writes.
246
+
247
+ It is also **why `cancelApi`'s explicit borrower-ownership check is essential rather than
248
+ defensive** — do not remove it as redundant.
249
+
250
+ See [ACL permission chain — unscoped grants](../../../2.0/apps/_underscore/features/acl-permission-chain.md)
251
+ for the general audit recipe.
252
+
152
253
  ## Frontend
153
254
 
154
255
  `GetSupport` now calls the real endpoint instead of the rejecting stub. `ServiceCard` /
@@ -169,6 +270,38 @@ feature rather than failing the page.
169
270
  was written and nearly shipped here: it would have left customers marked cancelled while PayPal
170
271
  kept billing them. `applyCancellationFields` loads a **fresh instance per write**. See
171
272
  [_Model::save() vs raw _Query](../../../2.0/apps/_underscore/features/model-save-vs-query-atomic-update.md).
273
+ - **⚠ Never test a cancellation column with `empty()` / `isset()`.** `_Model` has no `__isset`, so
274
+ `empty($s->dtRequestToCancel)` returns **TRUE while the column holds a date**. This broke the
275
+ **idempotency guard** here: `$isRequested` was permanently false, so an already-cancelled
276
+ subscription never short-circuited, **every click re-called PayPal**, and the crash-recovery branch
277
+ meant to preserve an earlier attempt's intent **wiped it instead**. Use
278
+ `trim((string) $field) !== ''`. See
279
+ [_Model magic-field access](../../../2.0/apps/_underscore/features/model-magic-field-access.md).
280
+ - **⚠ Guard the PayPal config with `_Config::isGroupSet('paypal')` — the `false` "optional" flag
281
+ does NOT rescue a missing group.** `_Config::paypal('client_id', false)` still **throws** if
282
+ `[paypal]` is absent from the active ini. Before this was fixed, a cancel in any environment
283
+ without a `[paypal]` section returned **`EO-1` with a stack trace to a logged-in retail
284
+ customer**. See
285
+ [_Config group access](../../../2.0/apps/_underscore/features/config-group-access.md).
286
+ - **⚠ The frontend must read `data.subscriptions.cancel` — nested TWICE, and keyed by the ROUTE
287
+ (`cancel`), not the phpMethod (`cancelApi`).** Reading `data.cancel` made the result `undefined`,
288
+ the frontend threw, and **the customer was told the cancellation FAILED while PayPal had already
289
+ been cancelled and correct state written** — the worst possible mismatch, because it invites a
290
+ retry against a completed irreversible action. Precedent that had it right:
291
+ `toga2-view/src/pages/ZipValidation/api/zipValidation.ts:88`. **A local test calling the model
292
+ method directly can never catch this** — it never traverses the envelope. See
293
+ [record scripts](../../../2.0/apps/api2/features/record-scripts.md).
294
+ - **⚠ `Call to undefined method _Model_Client_Subscription::cancelApi()` means the class is not
295
+ deployed, not that the code is broken.** `api.beta.togahub.com` runs `ENVIRONMENT=beta` and clones
296
+ `_underscore` branch **`_beta`** — merging to `_sandbox-dev` never reaches it (the
297
+ `sandbox-dev.ini` / `git.sandbox-dev.json` files there are dead leftovers). `class_exists` fails,
298
+ V2 silently declines the client override and falls back to the base model. Cost two deploy cycles.
299
+ See [ENVIRONMENT drives the _underscore branch](../../../2.0/apps/api2/features/environment-variable-drives-underscore-branch.md).
300
+ - **⚠ Errors are currently NOT captured on sandbox-dev/beta** — `Logs.Issue` is missing
301
+ `clickupPriority`, so the capture INSERT dies on MySQL 1054 and nothing is persisted; the only
302
+ trace is `identifiers.captureFailure` in the API response body. Debugging this feature on beta
303
+ means reading response bodies, not the Issues table. See
304
+ [error reporting](../../../2.0/apps/_underscore/features/error-reporting-issue-event.md).
172
305
  - **Do not add an `AclRecordPermissions` CREATE grant to "make the scripted POST work."** A scripted
173
306
  POST skips the record-permission check entirely; a CREATE grant on `Subscriptions` would let any
174
307
  portal customer insert arbitrary subscription rows through normal CRUD. An `EZ-1` on a scripted
@@ -180,8 +313,10 @@ feature rather than failing the page.
180
313
  - **The AIG carrier cancel is chained off the same `BILLING.SUBSCRIPTION.CANCELLED` event** — see
181
314
  [AIG contract creation](aig-contract-creation.md). AIG is the *carrier*, not the `Client_Aig`
182
315
  tenant.
183
- - **`api2/Config/production.ini` carries a plaintext live PayPal secret in the repo.** Flagged for
184
- remediation; do not propagate the pattern.
316
+ - **`api2/Config/production.ini` carries a plaintext live PayPal secret in the repo.** **Rotation
317
+ is outstanding as of 2026-08-06** — the credential was exposed again during this session's
318
+ testing. Treat it as compromised until rotated. Do not propagate the pattern, and never copy the
319
+ value into a doc, ticket, or scratch file (locations only).
185
320
  - **⚠ The confirmation channel this design depends on is currently BROKEN in production.** Every
186
321
  `Rate/Webhook` worker job is dying on the watchdog (unindexed `isEventProcessed()` LIKE — see
187
322
  [PayPal subscription purchase & webhook pipeline](paypal-subscription-purchase-webhook.md),
@@ -192,6 +327,30 @@ feature rather than failing the page.
192
327
 
193
328
  ## Change history
194
329
 
330
+ - 2026-08-06 — TRUE-80282 **testing & deploy**: **verified end-to-end on beta** (PayPal sandbox) —
331
+ `POST /v2/subscriptions/cancel` returned 200 with `dateCancel: 2026-08-27`, a real sandbox
332
+ agreement was cancelled, and `Subscriptions` id 84 confirmed `isActive = 1`, `dateEnd` untouched,
333
+ `dateCancelled` + `dtRequestToCancel` stamped, with the UI showing "Service ends by Aug 27, 2026"
334
+ and the Cancel affordance hidden. Recorded that the **entire worker2 half remains unverifiable off
335
+ production** (no webhooks, no cron scheduler) — gated on **TRUE-80575**. Fixed three bugs found in
336
+ testing: (1) the idempotency guard used `empty()` on a magic field, which is permanently true, so
337
+ every click re-called PayPal and the crash-recovery branch wiped the earlier attempt's intent;
338
+ (2) `_Config::paypal('client_id', false)` **still throws** when `[paypal]` is missing, so a cancel
339
+ in an unconfigured environment showed a logged-in customer an `EO-1` stack trace — `isGroupSet()`
340
+ is the only guard; (3) the frontend read `data.cancel` instead of `data.subscriptions.cancel`
341
+ (nested twice, keyed by **route** not phpMethod), telling the customer the cancellation **failed**
342
+ after PayPal had already been cancelled. Verified the `2026-08-04a` grants are already present in
343
+ sandbox-dev **and** prod (RecordFields 1696/1697/1698 on recordId 253) making the file a harmless
344
+ re-runnable no-op, and that `Client_Rate.Apis` uuid `fe4df2e2…` (`_Worker_Rate::API_UUID_RATE`)
345
+ carries **both `Base` and `API`**, so the worker inherits the grants. Added the second deploy-order
346
+ rule (**code before the registration migration**, else a confusing 500) and the beta branch trap
347
+ (`ENVIRONMENT=beta` → `_beta`; a missing override surfaces as
348
+ `_Model_Client_Subscription::cancelApi()` undefined — two deploy cycles lost). Logged a **⚠
349
+ pre-existing security finding**: role `Base` holds a fully unscoped CRUD grant on recordId 253 with
350
+ `isActive`/`dateEnd`/`dateCancelled`/`dtRequestToCancel`/`token` all writable in prod, letting a
351
+ customer extend their own coverage via plain CRUD and bypassing this feature's server-side date
352
+ computation — needs its own ticket. Added
353
+ `test/@Mark/Rate/verify_cancel_subscription.php` (34 assertions, never calls PayPal). (mhammontree)
195
354
  - 2026-08-04 — TRUE-80282: built portal self-service cancellation for Rate tech-support
196
355
  subscriptions. `POST /v2/subscriptions/cancel` (`{entitlementUuid}` in the **body** — the V2
197
356
  dispatcher consumes the trailing segment as the script name; the entitlement uuid avoids a joined
@@ -0,0 +1,91 @@
1
+ ---
2
+ type: session
3
+ slug: TRUE-80282-cancel-subscription
4
+ title: Verify Rate self-service subscription cancellation end-to-end on beta
5
+ author: mhammontree
6
+ repos: [_underscore, api2, toga2-view, worker2, dbchanges2, test]
7
+ framework: "2.0"
8
+ client: rate
9
+ status: active
10
+ created: 2026-08-06
11
+ updated: 2026-08-06
12
+ ---
13
+
14
+ # Session: TRUE-80282-cancel-subscription
15
+ **Date:** 2026-08-06
16
+ **Project/Repo:** _underscore · api2 · toga2-view · worker2 · dbchanges2 · test (2.0)
17
+ **Task:** Finish TRUE-80282 — prove the Rate portal's self-service tech-support cancellation works end-to-end (api2 → PayPal → DB → UI), fix whatever that surfaced, and commit. Continues the 2026-08-04 session of the same slug.
18
+
19
+ ---
20
+
21
+ ## What WORKED
22
+
23
+ - **End-to-end cancellation verified on beta (api.beta.togahub.com), 2026-08-06.** `POST /v2/subscriptions/cancel` with `{"entitlementUuid":"9c827c5c-7779-b260-b0cb-1d047a937a03"}` returned **200**, `data.subscriptions.cancel = {isCancelled: true, dateCancel: "2026-08-27", message: "Your subscription will be canceled on August 27, 2026. You'll have access until then."}`. A real PayPal **sandbox** agreement was cancelled.
24
+ - **Database state verified directly** (`Client_Rate.Subscriptions` id 84, sandbox-dev): `isActive = 1`, `dateEnd = 2026-08-27` (untouched), `dateCancelled = 2026-08-27`, `dtRequestToCancel = 2026-08-06 12:02:40`. That is the whole business rule proven — the customer keeps the coverage already paid for.
25
+ - **UI verified after refresh**: card renders "Monthly · $9.97/mo · **Service ends by Aug 27, 2026**", the Cancel button is gone, and the "you can cancel anytime" note is gone with it.
26
+ - **Verification script passes 34/34** — `test/@Mark/Rate/verify_cancel_subscription.php`. Covers the effective-date logic (incl. past/missing `dateEnd`), warranty-vs-tech discrimination, the borrower/item/subscription join, all six `cancelApi` pre-PayPal rejections (each asserting nothing was written), the idempotent double-click return, and the NULL-rollback regression. Never calls PayPal. Scratch rows self-clean (`leftover rows: 0` every run).
27
+ - **Field grants confirmed present, so worker2's wind-down guard will work.** `Subscriptions.dateEnd` (1696), `dateCancelled` (1697), `dtRequestToCancel` (1698) are all granted to role `Base` in **both** sandbox-dev and production. Decisive link: `Client_Rate.Apis` uuid `fe4df2e2-f685-4c03-a2d7-2015502dadc9` — exactly `_Worker_Rate::API_UUID_RATE` — carries **both** `Base` and `API`, so the worker inherits Base's grants.
28
+ - **Hardcoded Core.RecordFields id literals verified** — 1696/1697/1698 on `recordId 253` (route `subscriptions`) are correct per environment, so the migration's use of literals is sound.
29
+ - **All three migrations landed on beta**: CustomRecordScripts id 2 (`POST` / route `cancel` / `cancelApi`, granted to `Base`), and `Core.CronJobs` id 27 (`Rate/ExpireCancelledSubscriptions`, `0 3 * * *`, active). `2026-08-04a` was a confirmed no-op (grants already existed).
30
+ - **Commits** (all pushed except the last): `_underscore` `4636a8e` + `2fc8e450`, `worker2` `6de7fa7`, `dbchanges2` `df48181`, `api2` `47a4060`, `toga2-view` `6db0aec` + **`76d7d49` (not yet pushed)**.
31
+
32
+ ## What did NOT work — DO NOT RETRY THESE
33
+
34
+ - **`empty()` / `isset()` on a `_Model` field.** `_Model` defines `__get`/`__set` but **no `__isset`** (Model.php:464,481). Proven empirically: `$s->dateCancelled` returns `'2026-08-26'` while `empty($s->dateCancelled)` returns **true** and `isset()` returns **false**. This silently broke the cancel idempotency guard — `$isRequested` was permanently false, so an already-cancelled subscription never short-circuited, every click re-called PayPal, and the crash-recovery branch wiped the earlier attempt's intent instead of preserving it. **Use `trim((string) $field) !== ''`.** ⚠ This was **already documented** in `2.0/apps/_underscore/features/model-magic-field-access.md` — reading that doc first would have prevented the bug.
35
+ - **`_Config::<group>('key', false)` to survive a missing group.** It still throws. In `_Config::__callStatic` the missing-**group** branch tests `$properties[0]` *before* `array_shift()` has run, so it sees the property NAME (truthy) not the `false` flag, and falls through to the throw at Config.php:85. The optional-property form only rescues a missing key inside a group that **exists**. **Guard with `_Config::isGroupSet('<group>')` first.** Symptom before the fix: EO-1 with a stack trace to a logged-in customer.
36
+ - **Reading a scripted endpoint's response as `data.<script>`.** The real shape is **`data.<recordRoute>.<scriptRoute>`** — ours is `data.subscriptions.cancel` — and the key is the script's **route** (`cancel`), not its phpMethod (`cancelApi`). Reading `data.cancel` made the result `undefined`, so the frontend threw and told the customer the cancellation had **failed** while the backend had already cancelled at PayPal and written correct state. Precedent that had it right all along: `toga2-view/src/pages/ZipValidation/api/zipValidation.ts:88` → `response?.data?.entitlements?.["warranty-availability"]`.
37
+ - **Merging `_underscore` to `_sandbox-dev` and expecting api.beta.togahub.com to have it.** That environment runs **`ENVIRONMENT=beta`**, so `.ebextensions/git.php` clones `_underscore` branch **`_beta`** (`'_' . strtolower($env)`), because there is **no `.ebextensions/git.beta.json`** to override it. A `Config/sandbox-dev.ini` and a `git.sandbox-dev.json` (which pins `branch: _sandbox-dev`) both exist and point at the same host, apparently from a rename — they are **not** what that environment reads. **This cost two deploy cycles.** Diagnostic rule: `Call to undefined method _Model_Client_Subscription::cancelApi()` means the **client override class is not on the deployed server** — `class_exists` fails so V2 declines the override at V2.php:3155-3162 and falls back to the base model. It is not a code bug. ⚠ Also **already documented**, in `2.0/apps/api2/features/environment-variable-drives-underscore-branch.md`.
38
+ - **Raw SQL `UPDATE` then reload a model to assert** (in the test script). Returned the **stale pre-update row**: `_Model::load()` consults `_Database::$_modelCache`, which is a **separate cache from `useQueryCache(false)`** and is flushed **only** by `_Model::save()`. Two assertions failed against correct code. Fix: mutate the loaded model **in memory** when testing a pure function.
39
+ - **Defaulting the test script's Core schema to `'Core'`.** Locally `Core` is the **1.0** database and `Core_2` is the 2.0 one — verified: `Core` has 0/2 of `Records`+`RecordFields`, `Core_2` has 2/2. The fallback does not error, it silently registers the wrong schema and every Core lookup returns nothing. Fix: probe for the metadata tables and **assert** the result.
40
+ - **Cross-schema JOIN from `Client_Rate` to `Core` on the prod cluster.** Refused outright: `Unknown database 'Core'`. Live confirmation of why `Client_*` migrations cannot subselect `Core`.
41
+ - **Redacting a JSON *array of objects* by filtering top-level keys.** My filter keyed on `password`/`username` but the top level was numeric indices, so nothing matched and **a GitHub PAT was printed into the session transcript**. Extract the specific keys you want instead of filtering a shape you have not inspected. The PAT is in `api2/.ebextensions/git.json` (committed) — **treat as compromised and rotate**.
42
+
43
+ ## Not tried yet (candidates for next session)
44
+
45
+ - **The entire worker2 half — production-only.** `handleSubscriptionDeactivation`'s wind-down guard, the webhook `dateCancelled` reconciliation stamp, and `Rate/ExpireCancelledSubscriptions`. Non-prod receives **no** PayPal webhooks (no SQS on non-prod worker2; PayPal delivers to one configured URL) and has **no cron scheduler**, so none of it is reachable on beta. Consequence on beta: subscription 84 will sit at `isActive = 1` past 2026-08-27 — expected, not a bug. Gated on **TRUE-80575**.
46
+ - The idempotency guard against the real stack. Now unreachable through the UI (hiding the Cancel button is what prevents the second click) — would need a direct API call. Already covered by section 6 of the local script.
47
+ - The `Logs.Issue.clickupPriority` migration (offered, not written).
48
+ - Committing `verify_cancel_subscription.php` to the `test` repo.
49
+
50
+ ## Current file state
51
+
52
+ | File | Status | Notes |
53
+ |------|--------|-------|
54
+ | `_underscore/Component/Api/Paypal/Paypal.php` | committed `2fc8e450`, pushed | `isGroupSet` guard; 422 narrowed to `SUBSCRIPTION_STATUS_INVALID` |
55
+ | `_underscore/Model/Rate/Subscription.php` | committed `2fc8e450`, pushed | two-phase write; `empty()` replaced with cast-string compare |
56
+ | `worker2/Worker/Rate.php` | committed `6de7fa7`, pushed | wind-down guard, webhook confirmation, extension guard, expiry cron, unconfirmed reporting |
57
+ | `dbchanges2` × 3 | committed `df48181`, pushed | field grants (no-op where present), record script, cron |
58
+ | `api2/Config/beta.ini` | committed `47a4060`, pushed | `[paypal]` sandbox section — correct file, since the env reads `beta.ini` |
59
+ | `toga2-view` × 11 | committed `6db0aec`, pushed | button wiring, "Service ends by", error surfacing |
60
+ | `toga2-view` GetSupport api + viewModel | committed **`76d7d49`, NOT pushed** | envelope path fix (`data.subscriptions.cancel`) |
61
+ | `test/@Mark/Rate/verify_cancel_subscription.php` | **untracked** | 34/34 passing; not committed anywhere |
62
+ | `_underscore` 3 vendored CSS files | modified, deliberately uncommitted | someone else's `-webkit-` prefix cleanup — not this ticket |
63
+ | `worker2/Config/dev-markhammontree-laptop.ini` | modified, deliberately uncommitted | personal local config |
64
+ | `toga2-view/src/styles/index.css` | modified, deliberately uncommitted | line-endings only |
65
+ | `C:\WWW\TRUE-80282-Cancel-Plan.md` | rev 2 | team review doc |
66
+
67
+ ## Decisions made
68
+
69
+ - **Kept the `?: 'Core'`-style detection as probe-and-assert rather than hardcoding either name.** In production the 2.0 Core genuinely *is* `Core`, so neither name can be baked in; detection works in both without a flag. Rejected: a `CORE_DATABASE` env var as the primary mechanism (still supported as an override).
70
+ - **Unwrap the response envelope in the api layer, not the viewModel.** Keeps envelope knowledge in one place and gives the caller a single result to check — matching `zipValidation.ts`. Rejected: checking `response.data.subscriptions.cancel.isCancelled` in the viewModel.
71
+ - **Did not promote the `_Model` `__isset` trap to a standard.** `model-magic-field-access.md` already owns the subject; a second home would fragment the rule. It is cross-linked to the `save()` field-diff trap as a sibling.
72
+ - **Kept `dbchanges2/Client_Rate/2026-08-04a` even though it is a no-op** where the grants exist. It is harmless, re-runnable, documents the dependency, and still protects a fresh client blank.
73
+ - **Reported rather than acted on the ACL exposure.** It is pre-existing, and fixing permissions platform-wide is out of this ticket's scope.
74
+
75
+ ## Blockers
76
+
77
+ 1. **TRUE-80575 is the gate on the remaining verification** (the developer is picking it up next). Until `Rate/Webhook` jobs stop dying on the watchdog, `BILLING.SUBSCRIPTION.CANCELLED` is not processed in production, so the reconciliation channel — the safety net for a crash between the PayPal call and the `dateCancelled` write — is dead there.
78
+ 2. **⚠ Security, needs its own ticket.** In `Client_Rate` (sandbox-dev **and** production) role `Base` has `AclRecordPermissions` on recordId 253 with allowCreate/Read/Update/Delete all = 1 and **`appId`/`indirectRecordId` both NULL — no scoping** — plus `isWritable = 1` on `isActive`, `dateEnd`, `dateCancelled`, `dtRequestToCancel`, `token`. Every portal customer carries `Base`. Per the ACL data that permits extending one's own `dateEnd` (free coverage), un-cancelling, overwriting the PayPal `token`, deleting rows, and possibly acting on another customer's subscription. **Not exploited — permission tables were read only.** It directly undermines this feature's server-side date computation.
79
+ 3. **Error capture is broken on sandbox-dev.** `Logs.Issue` lacks `clickupPriority`, so the capture INSERT fails (MySQL 1054) and **no error is persisted on that environment** — it only appears inline as `identifiers.captureFailure`. From `_underscore` `4ebe12fe` shipping without its migration. Anyone debugging on beta is flying blind.
80
+ 4. **Two credentials need rotating**: the live PayPal `client_secret` (committed in `api2/Config/production.ini` and `worker2/Config/production.ini`) and the GitHub PAT in `api2/.ebextensions/git.json`.
81
+
82
+ ## Exact next step
83
+
84
+ > The developer is moving to **TRUE-80575** next. To close out TRUE-80282:
85
+ > 1. `cd /c/WWW/toga2-view && git push` — `76d7d49` (the envelope fix) is committed but unpushed.
86
+ > 2. Open the PRs **against the branch each environment actually reads** — this session proved `api.beta.togahub.com` runs `ENVIRONMENT=beta` and therefore `_beta`, **not** `_sandbox-dev`. Settle that mapping with Rohan before merging, since his instruction said `_sandbox-dev`. `dbchanges2` → `_main`.
87
+ > 3. Decide whether `test/@Mark/Rate/verify_cancel_subscription.php` is committed to the `test` repo (it is the regression test for both bugs fixed in `2fc8e450`, which the testing standard requires).
88
+ > 4. File the three follow-ups in blockers 2–4.
89
+
90
+ ---
91
+ _Saved by /session-save on 2026-08-06_
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.535",
3
+ "version": "1.0.536",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",