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.
- 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 +35 -2
- package/knowledge/2.0/apps/_underscore/features/model-magic-field-access.md +42 -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/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/standards/backend-testing.md +80 -1
- package/knowledge/INDEX.md +1 -1
- package/knowledge/clients/rate/INDEX.md +1 -1
- 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/package.json +1 -1
|
@@ -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"]
|
|
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-
|
|
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-
|
|
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` —
|
|
64
|
-
|
|
65
|
-
(`V2.php` ~:4121, :6305),
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
265
|
-
route `warranty-availability` and
|
|
266
|
-
**`data.entitlements["warranty-availability"]`** —
|
|
267
|
-
The sibling `addresses/validateAddress` reader
|
|
268
|
-
|
|
269
|
-
|
|
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-
|
|
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-
|
|
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)
|
package/knowledge/INDEX.md
CHANGED
|
@@ -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)_ —
|
|
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-
|
|
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.**
|
|
184
|
-
|
|
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