toga-ai 1.0.520 → 1.0.522

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.
@@ -13,7 +13,7 @@
13
13
  | [Carrier Shipping Labels (UPS/FedEx) & NetSuite Item Fulfillment](features/carrier-shipping-labels.md) | Backend mechanics behind TOGa Supply's Fulfill & Ship: buying a carrier label (UPS/FedEx), persisting it, and creating the NetSuite Item Fulfillment with tracki | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ItemFulfillments/TrackingNumber.php, _underscore/Component/Library/LabelPdf/LabelPdf.php, _underscore/Component/Library/Carriers/ShipmentRequest/ShipmentRequest.php, _underscore/Component/Library/Carriers/Ups/Ups.php, _underscore/Component/Library/Carriers/Fedex/Fedex.php, _underscore/Trait/Netsuite/ItemFulfillment.php, _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Component/Library/NetSuite/NetSuite.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ShippingMethod.php, _underscore/Model.php, _underscore/Cloud.php |
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
- | [_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 |
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
17
  | [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
18
  | [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, worker2/Worker/Infrastructure/Email/Send.php |
19
19
  | [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 |
@@ -24,7 +24,7 @@
24
24
  | [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
25
  | [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
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::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 |
27
+ | [_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
28
  | [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
29
  | [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 |
30
30
  | [Persona Name Translation (PersonaTranslations sidecar)](features/persona-name-translation.md) | Serves Persona **names** in multiple languages by adding a per-language **sidecar** table `PersonaTranslations`, reusing the platform's existing metadata-driven | _underscore/Model/Client/PersonaTranslation.php, dbchanges2/Client/2026-07-22b - PersonaTranslations.sql, dbchanges2/Core/2026-07-22a - PersonaTranslationsRecord.sql, dbchanges2/Client/2026-07-22c - PersonaTranslationsAcl.sql, dbchanges2/Client_CompassCanada/2026-07-22 - PersonaTranslationsFrench.sql, toga2-commerce/src/pages/Account/view/MySettingsView.tsx |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-03
9
+ updated: 2026-08-04
10
10
  owners: ["jcardinal", "mhammontree", "tcox"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -75,6 +75,47 @@ writable). Without it the field is rejected on write **and** is unreadable:
75
75
  role 1 actually reads the field). Grant `isWritable = 0` for a server-written field; resolve the role
76
76
  by name/subselect (ids differ per client DB). Beta-proven on Rate `Client_Rate` 2026-07-23.
77
77
 
78
+ ### ⚠ An ungranted field is ABSENT, not null — and an ungranted JOINED field fails the WHOLE request
79
+
80
+ Two distinct failure shapes, and the second is a page-killer:
81
+
82
+ - **A directly-requested field with no grant is simply missing from the response object** — it is
83
+ not `null`, so `'field' in obj` is false and any consumer that spreads or destructures gets
84
+ `undefined`. Code that checks `=== null` will not detect it.
85
+ - **A requested JOINED field with no grant fails the ENTIRE request with `EZ-2`.** One missing grant
86
+ on one joined column therefore blanks every record the page asked for — not just that column.
87
+
88
+ **Two practices follow directly:**
89
+
90
+ 1. **Mirror grants from a sibling field that is PROVABLY READ by every consumer you need — never
91
+ hardcode a role name.** A wrong role name **inserts cleanly and fails silently** (no FK error, no
92
+ rows matched, no signal). For Rate's cancellation columns the mirror was `Subscriptions.dateEnd`,
93
+ chosen because it is demonstrably read by **both** worker2 and the portal — so any role that can
94
+ see a subscription's end date can see its cancellation state.
95
+ 2. **Request non-essential fields as OPTIONAL and drop only the rejected field on `EZ-2`/`EV-8`.**
96
+ Then a missing grant degrades one feature instead of failing the page. `toga2-view`'s
97
+ `src/api/apiErrors.ts` is the precedent (see
98
+ [service card entitlements](../../../../clients/rate/features/service-card-entitlements.md)).
99
+ This is also why an API is sometimes designed to take a *different* identifier: Rate's cancel
100
+ endpoint takes the **entitlement** uuid precisely to avoid requesting the joined
101
+ `subscription.uuid` and betting the whole entitlements page on one more grant.
102
+
103
+ ### ⚠ Resolve `Core.RecordFields` ids by HARDCODED LITERAL in a `Client_*` migration — the subselect cannot run
104
+
105
+ This **overrides** the general "resolve by subselect, never hardcode" rule *for Core lookups from a
106
+ client-database file*. `Core` and `Client_*` are on **physically separate clusters**, so a
107
+ `SELECT … FROM Core.RecordFields` subselect inside a `Client_*` migration cannot resolve in
108
+ production — and locally the schema literally named `Core` is the **1.0** database, so it resolves
109
+ to the wrong schema there too. Either way the guarded `INSERT` matches nothing and **succeeds having
110
+ inserted zero rows**.
111
+
112
+ Use **hardcoded `Core.RecordFields` id literals** instead: those ids are explicitly seeded and
113
+ identical in every environment (the platform's only sanctioned hardcoded ids — see
114
+ `2.0/apps/dbchanges2/architecture.md`).
115
+
116
+ **Known-broken existing files (same root cause):** `Client_Rate/2026-07-23a` and
117
+ `Client/2026-07-16c`. A clean local run is never proof either worked.
118
+
78
119
  ### Standard field vs. custom (`c_`) field — TWO different permission tables
79
120
 
80
121
  The permission table depends on whether the field is a platform **standard** field or a client
@@ -244,6 +285,18 @@ assumed from one.
244
285
 
245
286
  ## Change history
246
287
 
288
+ - **2026-08-04** — TRUE-80282: sharpened the ungranted-field failure shapes — a directly-requested
289
+ ungranted field is **absent, not null** (so `=== null` checks miss it), while an ungranted
290
+ **joined** field fails the **whole request** (`EZ-2`), so one grant can blank an entire page. Added
291
+ the two practices that follow: **mirror a sibling field provably read by every consumer you need**
292
+ (Rate's cancellation columns mirrored `Subscriptions.dateEnd`, read by both worker2 and the
293
+ portal) rather than hardcoding a role name — a wrong role name inserts cleanly and fails silently;
294
+ and **request non-essential fields as optional**, dropping only the rejected field on
295
+ `EZ-2`/`EV-8`. Also recorded the **exception to "resolve by subselect"**: a `Core.RecordFields`
296
+ subselect from inside a `Client_*` migration **cannot resolve** (separate clusters in prod; `Core`
297
+ is the 1.0 DB locally) and inserts zero rows without erroring — use hardcoded Core RecordFields id
298
+ literals. Flagged `Client_Rate/2026-07-23a` (and the previously known `Client/2026-07-16c`) as
299
+ broken by this pattern. (mhammontree)
247
300
  - **2026-08-03** — TRUE-80494: reconfirmed the **silent-omission** read rule in a second environment
248
301
  (`measures.measureType` ungranted in dev-sandbox `Client_Growrk` → absent from `GET /v2/measures` →
249
302
  empty unit dropdowns, no error) and added the **verify-the-mirror-sibling** rule: a clone migration
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-08
10
- owners: [dfranks]
9
+ updated: 2026-08-04
10
+ owners: [dfranks, mhammontree]
11
11
  files:
12
12
  - _underscore/Loader.php
13
13
  - worker2/_.php
@@ -15,6 +15,7 @@ files:
15
15
  - worker2/Component/Forecast/Db/Db.php
16
16
  - worker2/Component/Forecast/SaleImport/SaleImport.php
17
17
  - api2/Component/Api/Netsuite/Netsuite.php
18
+ - _underscore/Component/Api/Paypal/Paypal.php
18
19
  related:
19
20
  - ../architecture.md
20
21
  - ./forecast-sale-import.md
@@ -40,6 +41,25 @@ is no config toggle. If the class is declared in the **global** namespace instea
40
41
  **This is NOT caught by `php -l`** (parse-only) — it only surfaces at autoload/runtime, so
41
42
  lint-clean code can still fatally throw on the first request that touches the class.
42
43
 
44
+ ## ⚠ FILE LAYOUT — a `_Component_*` is a DIRECTORY, never a flat file
45
+
46
+ Before the namespace rule even applies, the autoloader has to *find* the file.
47
+ `_Loader::autoloader()` (`Loader.php:60-67`) **pops the last `_`-segment of the class name and
48
+ appends it as BOTH an extra directory AND the filename**:
49
+
50
+ ```
51
+ _Component_Api_Paypal → Component/Api/Paypal/Paypal.php ✅
52
+ → Component/Api/Paypal.php ❌ fatal
53
+ ```
54
+
55
+ A flat `Component/Api/Paypal.php` fatals with **"Could not find required file"** on first use — a
56
+ runtime-only failure, invisible to `php -l`, exactly like the namespace error below.
57
+
58
+ **Every existing `_Component_Api_*` is a directory** — `Toga`, `Clickup`, `Elite`, `Hubspot`,
59
+ `Netsuite`, `Startech`, `Wje`. Match that shape when adding a new one (first re-confirmed
60
+ 2026-08-04 adding `_Component_Api_Paypal` for
61
+ [Rate subscription cancellation](../../../../clients/rate/features/subscription-cancellation.md)).
62
+
43
63
  ## How it works — `_underscore/Loader.php` (lines ~71-83)
44
64
  For a project-local `_Component_*` / `_Model_*` class the autoloader:
45
65
  1. `require`s the local file.
@@ -85,6 +105,9 @@ transactions, the symptom **presented as ~7 months of historical drift** rather
85
105
  project-local `_Component_*`/`_Model_*` must still declare the project namespace.)
86
106
 
87
107
  ## Gotchas / known issues
108
+ - **A `_Component_*` placed as a flat file instead of `<Name>/<Name>.php` fatals with "Could not
109
+ find required file"** — the autoloader appends the trailing segment as both directory and
110
+ filename. Also runtime-only. Check the file *path* before debugging the namespace.
88
111
  - `php -l` will **not** catch a missing/global namespace or an unqualified `catch`/`throw`/
89
112
  `extends` — boot the class (autoload it) to verify. A wrong autoload path is likewise a
90
113
  runtime-only failure.
@@ -92,6 +115,13 @@ project-local `_Component_*`/`_Model_*` must still declare the project namespace
92
115
  unqualified `catch (Exception $e)` fails **silently** — the more insidious of the two.
93
116
 
94
117
  ## Change history
118
+ - 2026-08-04 — TRUE-80282: documented the **file-layout** half of `_Component_*` loading, which
119
+ precedes the namespace rule — `_Loader::autoloader()` (`Loader.php:60-67`) pops the trailing
120
+ `_`-segment and appends it as **both a directory and the filename**, so
121
+ `_Component_Api_Paypal` must live at `Component/Api/Paypal/Paypal.php`; a flat
122
+ `Component/Api/Paypal.php` fatals with "Could not find required file" on first use (runtime-only,
123
+ invisible to `php -l`). Confirmed every existing `_Component_Api_*` (Toga, Clickup, Elite, Hubspot,
124
+ Netsuite, Startech, Wje) is a directory. (mhammontree)
95
125
  - 2026-07-08 — **Production recurrence** of this exact fatal (concrete fingerprint, no doc change
96
126
  to the mechanism): a worker2 deploy declared `_Component_Forecast_SaleImport`
97
127
  (`Worker/Netsuite/Invoice.php` autoload) and its sibling `_Component_Forecast_Db`
@@ -6,14 +6,16 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-17
10
- owners: ["dfranks"]
9
+ updated: 2026-08-04
10
+ owners: ["dfranks", "mhammontree"]
11
11
  files:
12
12
  - _underscore/Model.php
13
13
  - _underscore/Query.php
14
+ - _underscore/Model/Rate/Subscription.php
14
15
  related:
15
16
  - ./model-magic-field-access.md
16
17
  - ../../worker2/features/netsuite-opportunity-sync.md
18
+ - ../../../../clients/rate/features/subscription-cancellation.md
17
19
  ---
18
20
 
19
21
  ## Summary
@@ -57,8 +59,50 @@ write-drop). All **non-claim** writes stay on the ORM — `save()` is fine for p
57
59
  load → set → save → `transactionCommit` by primary key (the idiom
58
60
  `_Worker_Netsuite_Opportunity::delete()` already uses).
59
61
 
62
+ ## ⚠ `initial` is never resynced after a save — so a SECOND `save()` on the same instance silently drops the column
63
+
64
+ `save()` only emits columns where **`initial !== value`** (`Model.php:244`), and `initial` is
65
+ populated **only at load/initialize** (`Model.php:702`) — it is **never refreshed after a save**.
66
+ Consequences on a **reused instance**:
67
+
68
+ ```php
69
+ $m->f = $v; $m->save(); // UPDATE … SET f = $v (initial['f'] is still the ORIGINAL)
70
+ $m->f = null; $m->save(); // f is OMITTED entirely — the rollback does NOTHING
71
+ ```
72
+
73
+ The second write compares `null` against the *pre-first-save* `initial`, which was already `null`,
74
+ sees "no change", and emits no column. **Any "set it back and save" rollback / compensating write on
75
+ a reused `_Model` instance is a silent no-op.** This is broader than the no-diff rule below: the
76
+ diff is computed against stale state, not against what is actually in the database.
77
+
78
+ **It fails in the most dangerous possible way** — the code lints, reads correctly, and returns
79
+ success. Concrete cost: a PayPal-failure rollback in
80
+ [Rate subscription cancellation](../../../../clients/rate/features/subscription-cancellation.md)
81
+ would have left customers **marked cancelled while PayPal kept billing them**, with no error
82
+ anywhere.
83
+
84
+ **Fix — load a FRESH instance per write:**
85
+
86
+ ```php
87
+ // Do NOT reuse the instance across writes. Reload, and defeat both caches so the
88
+ // reload cannot hand back the pre-write row from earlier in this same request.
89
+ \_Database::useQueryCache(false);
90
+ $model->setIsReadHostEnabled(false); // don't read the replica
91
+ // … load fresh, set the field, save.
92
+ ```
93
+
94
+ Both flags matter: the query cache can return the pre-write row within the same request, and the
95
+ read host is a replica that may not have the write yet. (`useQueryCache` is a **global** toggle —
96
+ capture and restore the prior value.)
97
+
98
+ Applies to **any** multi-write sequence on one instance, not just rollbacks: two-phase writes,
99
+ retry loops, and compensating transactions all need the reload.
100
+
60
101
  ## Gotchas / known issues
61
102
 
103
+ - **Never reuse a `_Model` instance for a second write** (see above) — `initial` is not resynced, so
104
+ the second `save()` drops the column and the write silently vanishes. Reload a fresh instance with
105
+ `useQueryCache(false)` + `setIsReadHostEnabled(false)`.
62
106
  - Do **not** reach for `save()` to implement a row-claim / optimistic-concurrency guard — it silently
63
107
  degrades to a non-atomic check-then-act because its `WHERE` is primary-key-only and it exposes no
64
108
  affected-rows count. Use raw `_Query` + `getAffectedRows()`.
@@ -67,6 +111,14 @@ load → set → save → `transactionCommit` by primary key (the idiom
67
111
 
68
112
  ## Change history
69
113
 
114
+ - 2026-08-04 — TRUE-80282: documented that **`initial` is never resynced after a save**
115
+ (`Model.php:244` diffs on `initial !== value`; `initial` set only at load/initialize,
116
+ `Model.php:702`), so a **second `save()` on the same instance omits the column entirely** — every
117
+ "set it back to null and save" rollback / compensating write on a reused instance is a silent
118
+ no-op. Recorded the required fix (fresh instance per write, with `_Database::useQueryCache(false)`
119
+ + `setIsReadHostEnabled(false)` so the reload can't return the pre-write row). Found via a PayPal
120
+ rollback that would have left Rate customers marked cancelled while still being billed.
121
+ (mhammontree)
70
122
  - 2026-07-17 — Documented that `_Model::save()` cannot perform an atomic conditional update
71
123
  (primary-key-only `WHERE`, skips no-diff writes, no affected-rows count) and that raw `_Query` +
72
124
  `_Query::getAffectedRows()` is the required surface for any DB row-claim / optimistic-concurrency
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-03
9
+ updated: 2026-08-04
10
10
  owners: ["kyalamarthi", "mhammontree"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -218,6 +218,29 @@ CLIENT database as a `CustomRecordScript` — NOT in `Core.RecordScripts`.** Fir
218
218
  - Verified by applying the file **twice** to two environments: exactly one script row and one
219
219
  grant row each.
220
220
 
221
+ ## 🔒 A scripted POST BYPASSES the record-level `AclRecordPermissions` check entirely
222
+
223
+ `V2.php:4472` guards the record-permission check with:
224
+
225
+ ```php
226
+ if (!$isUsingScriptedCall && empty($routePairs)) { /* … record-level ACL check … */ }
227
+ ```
228
+
229
+ So for a scripted call, **authorization comes solely from `AclCustomRecordScripts` /
230
+ `AclRecordScripts`** — the four-table record-CRUD chain is never consulted.
231
+
232
+ **⚠ Therefore: never add an `AclRecordPermissions` CREATE grant to "make a scripted POST work."**
233
+ It cannot be the cause, and it opens a real hole. Concrete case: adding a CREATE grant on
234
+ `Subscriptions` (to debug a 403 on `POST /v2/subscriptions/cancel`) would have let **any portal
235
+ customer insert arbitrary subscription rows via ordinary CRUD** — the script grant was the actual
236
+ missing piece.
237
+
238
+ **Debugging rule: an `EZ-1` on a scripted POST means the SCRIPT grant is missing** (or the
239
+ `RecordScripts`/`CustomRecordScripts` row doesn't match method+route), never a missing CREATE
240
+ permission. See the
241
+ [ACL permission chain](../../_underscore/features/acl-permission-chain.md) for the CRUD chain this
242
+ path deliberately skips.
243
+
221
244
  ## Data source note (core DB hosts/credentials)
222
245
 
223
246
  The core cluster hosts and credentials this endpoint reads through are **not** documented here;
@@ -231,6 +254,13 @@ an `AclRecordScripts` grant in each client DB.
231
254
 
232
255
  ## Gotchas / known issues
233
256
 
257
+ - **A scripted route cannot carry an id BEFORE the script segment.** The dispatcher consumes the
258
+ **trailing path segment as the script name**, so `/v2/<record>/{id}/<script>` does not match —
259
+ pass the id in the query string or the JSON body instead. This is why Rate's cancel endpoint is
260
+ `POST /v2/subscriptions/cancel` with `{entitlementUuid}` in the body
261
+ ([cancellation doc](../../../../clients/rate/features/subscription-cancellation.md)).
262
+ - **A scripted POST skips the record-level ACL check** — see the section above. Do not "fix" a
263
+ scripted-POST 403 with an `AclRecordPermissions` CREATE grant.
234
264
  - **The response key is the ROUTE VERBATIM, not the `phpMethod`.** A script registered with
235
265
  route `warranty-availability` and `phpMethod` `warrantyAvailability` returns
236
266
  **`data.entitlements["warranty-availability"]`** — *not* `data.entitlements.warrantyAvailability`.
@@ -257,6 +287,14 @@ an `AclRecordScripts` grant in each client DB.
257
287
 
258
288
  ## Change history
259
289
 
290
+ - 2026-08-04 — TRUE-80282: documented that a **scripted POST bypasses the record-level
291
+ `AclRecordPermissions` check entirely** (`V2.php:4472` guards it with
292
+ `if (!$isUsingScriptedCall && empty($routePairs))`), so authorization rests solely on the script
293
+ grant — and that adding a CREATE grant to "fix" a scripted-POST `EZ-1` is both ineffective and a
294
+ privilege escalation (on `Subscriptions` it would let any portal customer insert subscription rows
295
+ via normal CRUD). Also recorded that a scripted route **cannot carry an id before the script
296
+ segment** (the trailing segment *is* the script name), so ids go in the query string or body.
297
+ (mhammontree)
260
298
  - 2026-08-03 — TRUE-80494: documented that argument binding is **PHP named-argument unpacking of the
261
299
  query string** (`parse_str` + merged JSON body, query wins, `$args['api']` last, then
262
300
  `$model::$phpMethod(...$args)`; `V2.php` ~4202/~4468) and its two consequences: (a) a script
@@ -2,7 +2,7 @@
2
2
 
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
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/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 |
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
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 |
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 |
@@ -6,9 +6,11 @@ project: TOGa View Frontend
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-06-24
10
- owners: [apeterson]
9
+ updated: 2026-08-04
10
+ owners: [apeterson, mhammontree]
11
11
  files:
12
+ - toga2-view/tsconfig.json
13
+ - toga2-view/tsconfig.app.json
12
14
  - toga2-view/src/main.tsx
13
15
  - toga2-view/src/App.tsx
14
16
  - toga2-view/src/routes.tsx
@@ -148,6 +150,12 @@ Routes are defined in `src/routes.tsx` with `createBrowserRouter`:
148
150
  - **Tooling:** `npm run dev` (Vite dev server, port 5173), `npm run build`
149
151
  (`tsc && vite build --mode production`), plus `buildBeta` / `buildGamma`. `npm run lint`
150
152
  runs ESLint with `--max-warnings 0`. E2E via Cypress (`npm run cypress`).
153
+ - **⚠ `npx tsc --noEmit` at the repo root checks NOTHING.** The root `tsconfig.json` is a
154
+ **references-only solution file**, so it type-checks zero files — a deliberately injected type
155
+ error still exits 0. This makes it a silent false-green in any pre-commit or CI-style check.
156
+ **Always type-check with `npx tsc -p tsconfig.app.json --noEmit`.** That config has
157
+ **pre-existing errors**, so the pass criterion is "no *new* errors vs. a stashed baseline", not
158
+ zero errors — diff against the baseline rather than expecting a clean run.
151
159
  - **Many environments:** `.env.{development,alpha,beta,gamma,sprint,stage,test,production}`.
152
160
  Each supplies the `VITE_API_*` base URLs consumed by `axiosInstance.ts`. A client/brand is
153
161
  selected by hostname at runtime (not a separate build), via the `VITE_API_<SUBDOMAIN>`
@@ -169,6 +177,9 @@ Routes are defined in `src/routes.tsx` with `createBrowserRouter`:
169
177
  page/viewModel that consumes it — check call sites before changing its shape.
170
178
  - **Tokens live in `localStorage`** (`accessToken`/`refreshToken`/`user`). Treat them as
171
179
  sensitive; never log token values or echo them into Sentry extras.
180
+ - Type-check with `npx tsc -p tsconfig.app.json --noEmit` — the root `tsconfig.json` is
181
+ references-only and checks nothing (false green). Compare against a stashed baseline; the app
182
+ config has pre-existing errors.
172
183
 
173
184
  ## Client notes
174
185
 
@@ -92,9 +92,15 @@ Two things about this flow are easy to get wrong and are the reason it is docume
92
92
 
93
93
  5. **Confirm flow.** `handleConfirmCancel()` awaits `cancelSubscription()`; it closes the modal
94
94
  **only** on a genuine resolve, and on rejection keeps the modal open and renders
95
- `subscriptionFields.confirm.errorMessage`. Since the stub always throws, today's real-world
96
- outcome is always the error state intentional, so nothing reports a cancellation that did not
97
- happen.
95
+ `subscriptionFields.confirm.errorMessage`. `cancelSubscription()` now issues the real
96
+ `POST /v2/subscriptions/cancel`, so a resolve means the server both wrote the cancellation
97
+ request **and** got a PayPal confirmation. Preserve the reject-on-anything-else behavior: the
98
+ server intentionally fails the call rather than half-cancelling.
99
+
100
+ 6. **Post-cancellation display.** Once a cancellation is pending, the service card renders
101
+ **"Service ends by \<date\>"** instead of "Renews", and the Cancel affordance is hidden — the
102
+ subscription stays `isActive = 1` until its period ends, so the *absence* of a cancel button (not
103
+ the absence of the card) is what signals the pending state.
98
104
 
99
105
  ## Decision — gate on `serviceType`, not on `isRateWarranty`
100
106
 
@@ -115,9 +121,13 @@ not self-service cancellable from the portal; only tech-support subscriptions ar
115
121
  - **The cancel UI lives only on `GetSupportPage.tsx`.** `ServiceCard` has no cancel affordance —
116
122
  do not go looking for one in `components/`, and do not add a second entry point without
117
123
  reproducing the same `showCancelUi` gate.
118
- - **No backend endpoint exists.** `cancelSubscription()` throws
119
- `"Subscription cancellation is not available yet."` by design (reject, never resolve). Swap the
120
- body for the real API call when the endpoint ships; do not "fix" it by resolving.
124
+ - **The `serviceType === 'tech'` gate is a UX affordance, NOT the enforcement point.** The backend
125
+ refuses warranty cancellations independently, using the WH purchase guard's authority (title
126
+ keyword or `Items.id 3`). Do not weaken the server check because the button is already hidden.
127
+ - **The cancel endpoint takes `entitlementUuid`, not the subscription uuid.** Passing
128
+ `subscription.uuid` will not work and requesting that field would need a new joined-field ACL
129
+ grant whose absence fails the whole entitlements request. See the
130
+ [cancellation doc](../../../../clients/rate/features/subscription-cancellation.md).
121
131
  - **`detectServiceType` now has a second consumer with a policy consequence.** Widening its
122
132
  `'tech'` keywords does not just change a card row any more — it **exposes the cancel button** for
123
133
  whatever newly matches. Check both call sites before touching the classifier.
@@ -141,6 +151,14 @@ same user, so the page must distinguish them per service rather than per account
141
151
 
142
152
  ## Change history
143
153
 
154
+ - 2026-08-04 — TRUE-80282: **the backend shipped**, so `cancelSubscription()` is no longer a
155
+ rejecting stub — it calls `POST /v2/subscriptions/cancel` with `{ entitlementUuid }` in the body.
156
+ Recorded that the `serviceType === 'tech'` gate is a UX affordance only (the server refuses
157
+ warranties on its own authority), that the argument is the **entitlement** uuid, and that a
158
+ pending cancellation renders "Service ends by \<date\>" with the Cancel affordance hidden while
159
+ the subscription stays active. Full backend behavior:
160
+ [Rate tech-support subscription cancellation](../../../../clients/rate/features/subscription-cancellation.md).
161
+ (mhammontree)
144
162
  - 2026-07-28 — Cancel Subscription UI gated by service type: viewModel exposes
145
163
  `canCancelSubscription` (`activeService?.serviceType === "tech"`), and the view's `showCancelUi`
146
164
  now gates the button, the confirm modal, and the `cancelNote` sentence. WH warranty and
@@ -5,7 +5,7 @@
5
5
  | [Rate AIG Warranty Contract Creation (silent-failure interceptor)](features/aig-contract-creation.md) | 2.0 | When a Rate entitlement is created, `_Model_Rate_Entitlement::postPost` (`_underscore/Model/Rate/Entitlement.php`) creates an **AIG warranty contract** as a non | _underscore/Model/Rate/Entitlement.php, _underscore/ApiRequest.php, worker2/Worker/Monitors/RateEntitlement.php |
6
6
  | [Rate Monthly Reconciliation Report](features/monthly-reconciliation-report.md) | 1.0 | A monthly cron that emails an Excel reconciliation report covering all Rate subscription sales orders and their linked PayPal payments for the prior calendar mo | worker/crons/notifications/reports/rate/send_monthly_rate_purchases_report.php, worker/schedules/cron.worker.notification.json |
7
7
  | [Rate SalesOrder → NetSuite CashSale Export (postPost)](features/netsuite-cashsale-export.md) | 2.0 | Rate sells home-warranty / home-tech-support products. | _underscore/Model/Rate/SalesOrder.php, _underscore/Model/Rate/Item.php |
8
- | [Rate PayPal Subscription Purchase & Webhook Pipeline](features/paypal-subscription-purchase-webhook.md) | 2.0 | > # ⚠ STATUS (2026-08-04, TRUE-80575) — TWO INDEPENDENT LIVE DEFECTS > > **A. | worker2/Worker/Rate.php, worker2/Config/production.ini, api2/Config/production.ini, _underscore/Component/Api/Paypal.php, toga2-view/src/hooks/usePayPalSubscription.ts, toga2-view/src/services/paypalService.ts, toga2-view/src/pages/CheckOut/viewModel/useCheckoutPageViewModel.ts, toga2-view/src/pages/CheckOut/api/checkoutApi.ts, toga2-view/src/pages/Activation/view/Activation.tsx |
8
+ | [Rate PayPal Subscription Purchase & Webhook Pipeline](features/paypal-subscription-purchase-webhook.md) | 2.0 | > # ⚠ STATUS (2026-08-04, TRUE-80575) — TWO INDEPENDENT LIVE DEFECTS > > **A. | worker2/Worker/Rate.php, worker2/Config/production.ini, api2/Config/production.ini, _underscore/Component/Api/Paypal/Paypal.php, toga2-view/src/hooks/usePayPalSubscription.ts, toga2-view/src/services/paypalService.ts, toga2-view/src/pages/CheckOut/viewModel/useCheckoutPageViewModel.ts, toga2-view/src/pages/CheckOut/api/checkoutApi.ts, toga2-view/src/pages/Activation/view/Activation.tsx |
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 |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: rate
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-07-27
9
+ updated: 2026-08-04
10
10
  owners: [mhammontree, tcox]
11
11
  files:
12
12
  - _underscore/Model/Rate/Entitlement.php
@@ -15,6 +15,7 @@ files:
15
15
  related:
16
16
  - clients/rate/profile.md
17
17
  - clients/rate/features/whole-home-warranty-purchase-guard.md
18
+ - clients/rate/features/subscription-cancellation.md
18
19
  - ../../../2.0/apps/worker2/features/monitoring-framework.md
19
20
  ---
20
21
 
@@ -99,6 +100,12 @@ creation vs. cancellation. This is an accepted, documented limitation — not an
99
100
  address, so an entitlement with no address never even attempts contract creation (and
100
101
  never logs a `/contract` call).
101
102
  - **Auth-induced failures are blind to the monitor** — see the detection limitation above.
103
+ - **The AIG carrier cancel (`/contract/cancel`) is chained off the SAME
104
+ `BILLING.SUBSCRIPTION.CANCELLED` event** that worker2 uses to confirm a portal cancellation — so
105
+ the two flows share a trigger and a failure mode: while `Rate/Webhook` jobs are timing out, neither
106
+ the AIG cancel nor the `dateCancelled` stamp happens. See
107
+ [subscription cancellation](subscription-cancellation.md). **"AIG" here is the *carrier*, not the
108
+ `Client_Aig` tenant** — do not resolve it as a client slug.
102
109
  - **The AIG-success branch REASSIGNS `$payload->uuid`** (`$payload->uuid = _String::generateUuid()`,
103
110
  repurposed as the AIG contract number) — and it does so **in the same `postPost`** that pins the WH
104
111
  service address. On the pre-redesign build the pin step ran **after** this reassignment and keyed
@@ -110,6 +117,11 @@ creation vs. cancellation. This is an accepted, documented limitation — not an
110
117
 
111
118
  ## Change history
112
119
 
120
+ - 2026-08-04 — TRUE-80282: noted that the AIG carrier cancel (`/contract/cancel`) is chained off the
121
+ **same** `BILLING.SUBSCRIPTION.CANCELLED` webhook event that confirms a portal subscription
122
+ cancellation, so both share a trigger and are both blocked by the current `Rate/Webhook` watchdog
123
+ failures; and that "AIG" in this flow is the **carrier**, not the `Client_Aig` tenant.
124
+ (mhammontree)
113
125
  - 2026-07-27 — TRUE-79533 beta-verified: updated the service-address note to the **standard**
114
126
  `Entitlements.serviceAddressId` FK (the Rate-custom `c_serviceAddressId` was promoted to a
115
127
  base-model standard field), and noted that this AIG block and the WH pin read the **same**
@@ -12,7 +12,7 @@ files:
12
12
  - worker2/Worker/Rate.php
13
13
  - worker2/Config/production.ini
14
14
  - api2/Config/production.ini
15
- - _underscore/Component/Api/Paypal.php
15
+ - _underscore/Component/Api/Paypal/Paypal.php
16
16
  - toga2-view/src/hooks/usePayPalSubscription.ts
17
17
  - toga2-view/src/services/paypalService.ts
18
18
  - toga2-view/src/pages/CheckOut/viewModel/useCheckoutPageViewModel.ts
@@ -70,7 +70,7 @@ token"*, `Rate.php:394` throws *"Entitlement not found"*.
70
70
  ## Gotchas
71
71
 
72
72
  - **The watchdog timeout is NOT the PayPal-egress problem — do not re-chase egress.**
73
- `worker2/Worker/Rate.php:59-77` and `_underscore/Component/Api/Paypal.php:13-18` both
73
+ `worker2/Worker/Rate.php:59-77` and `_underscore/Component/Api/Paypal/Paypal.php:13-18` both
74
74
  assert that webhook stalls come from the EB worker tier being unable to reach
75
75
  `api-m.paypal.com`. That egress problem is real but is **not** what kills these jobs:
76
76
  `verify_signature = false` in `worker2/Config/production.ini`, so the signature/OAuth
@@ -6,7 +6,7 @@ project: TOGa View Frontend
6
6
  client: rate
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-07-28
9
+ updated: 2026-08-04
10
10
  owners: ["bala", "tcox", "mhammontree"]
11
11
  files:
12
12
  - src/components/ServiceCard/ServiceCard.tsx
@@ -24,6 +24,7 @@ files:
24
24
  related:
25
25
  - clients/rate/profile.md
26
26
  - whole-home-warranty-purchase-guard.md
27
+ - subscription-cancellation.md
27
28
  - 2.0/apps/toga2-view/features/get-support-cancel-subscription.md
28
29
  ---
29
30
 
@@ -107,8 +108,21 @@ product types would need `detectServiceType` extended or overridden.
107
108
  unclassified services get no cancel path). See
108
109
  [Get Support — Subscription Details & Cancel Subscription Gating](../../../2.0/apps/toga2-view/features/get-support-cancel-subscription.md).
109
110
 
111
+ ## Pending-cancellation display ("Service ends by …")
112
+
113
+ A cancelled-but-not-yet-expired subscription **still has `isActive = 1`** (access is deliberately
114
+ retained through the paid period — see
115
+ [subscription cancellation](subscription-cancellation.md)), so it keeps rendering a card. The card
116
+ switches its date row from **"Renews \<date\>"** to **"Service ends by \<date\>"** once
117
+ `dtRequestToCancel` is set, and the Cancel affordance is hidden. `homeApi` / `useBundleServices`
118
+ therefore also request the cancellation columns — as **optional** fields, dropped on `EZ-2`/`EV-8`
119
+ by `apiErrors.ts`, so a missing grant loses the "ends by" wording rather than the whole page.
120
+
110
121
  ## Gotchas / known issues
111
122
 
123
+ - **A pending cancellation is NOT visible in `isActive`.** `isActive` stays `1` until the nightly
124
+ expiry cron. Any UI that infers "still subscribed" from `isActive` alone will show "Renews" to a
125
+ customer who already cancelled — read `dtRequestToCancel` / `dateCancelled`.
112
126
  - **`ojoin` is required for address** — using INNER JOIN (`join`) drops every entitlement
113
127
  whose contact has no `primaryContactAddressId`, silently hiding those cards.
114
128
  - **`T00:00:00` in date formatting** — `formatDate` appends this before parsing to avoid
@@ -147,6 +161,11 @@ unclassified services get no cancel path). See
147
161
 
148
162
  ## Change history
149
163
 
164
+ - 2026-08-04 — TRUE-80282: cards now render **"Service ends by \<date\>"** instead of "Renews" for a
165
+ pending cancellation (and hide the Cancel affordance). Recorded that such a subscription still has
166
+ `isActive = 1` until the nightly expiry cron, so `isActive` alone must not be read as "still
167
+ subscribed", and that the new cancellation columns are requested as **optional** fields so a
168
+ missing grant degrades the wording rather than the page. (mhammontree)
150
169
  - 2026-07-28 — `detectServiceType` gained a second, non-display consumer: the Get Support page gates
151
170
  its Cancel Subscription UI on `serviceType === 'tech'`, so the classifier now decides which
152
171
  entitlements are self-service cancellable. (tcox)
@@ -0,0 +1,69 @@
1
+ ---
2
+ type: session
3
+ slug: surface-approval-details-layout
4
+ title: SalesOrder approval-details card — layout split, title, submitted-by suffix
5
+ author: apeterson
6
+ repos: [toga25-supply, dbchanges2]
7
+ framework: "2.0"
8
+ client: shared
9
+ status: active
10
+ created: 2026-08-04
11
+ updated: 2026-08-04
12
+ ---
13
+
14
+ # Session: surface-approval-details-layout
15
+ **Date:** 2026-08-04
16
+ **Project/Repo:** toga25-supply (2.0) + dbchanges2 (Core SQL)
17
+ **Task:** Reshape the SalesOrders record-modal approval-details card — separate approval-stage layout, surface-driven section title, and an inline "submitted by" title suffix — while keeping the surface/FE boundary clean.
18
+
19
+ ---
20
+
21
+ ## What WORKED
22
+ <!-- Include specific file paths and evidence -->
23
+ - **Two-layout component split** (approval vs standard). `SalesOrderView.tsx` branches on `isApprovalDetailsActive` → `SalesOrderApprovalSummaryGrid` (new) vs `SalesOrderSummaryGrid`. Approval grid: approval-details spans 3 cols, order-summary totals 1 col (`.approvalSummaryGrid`/`.approvalDetailsSpan`). Chosen over prop-driven branching because the difference is placement across the DOM tree, not styling. tsc -b clean on all touched files.
24
+ - **Shared machinery extracted** so both grids reuse it: `helpers/sectionRenderers.tsx` (`createSectionRenderers` factory — detailSection/approvalDetails/locationCard/totalsCard) and `helpers/getVisibleSections.ts` (isVisible + visibilityRule + approval-suppression filter).
25
+ - **Section relocation in approval view** via `getDetailSections.tsx`: Order Details + Bill To move below the Items table, side-by-side (Order Details first, then Bill To — user reordered); Admin Notes moves to the TOP of the modal (rendered in `SalesOrderView`, suppressed from below-items). Guarded so absent sections don't throw for non-approval clients.
26
+ - **Surface-driven 2-col field grid** for the approval-details card (`.approvalFieldsGrid` = `repeat(2, minmax(0,1fr))`), reflows via BaseDetailField's `grid-column: span var(--field-col-span,1)`.
27
+ - **Section title** wired end-to-end from surface: confirmed resolver emits `titleMessageKey` from `Core.Surfaces.titleMessageId` (`_underscore/Model/Core/Surface.php:634-635`); `buildSection` already resolves `sectionTitle` from it — so it's a pure DB change. Migration `dbchanges2/Core/2026-08-04a - ApprovalDetailsSectionTitle.sql` inserts the message + links the surface.
28
+ - **"Submitted by {name}" title suffix**: added a `config.role: 'titleSuffix'` marker pattern. `buildSection` pulls that element OUT of the grid fields and exposes `section.titleSuffix = {label, valueKey}`; the `approvalDetails` renderer draws it inline in the `<h2>` (dot-path value resolve), styled `.cardTitleSuffix` (muted/italic). Migration `2026-08-04b` stamps the role on the `createdByUser._name` element. tsc clean.
29
+
30
+ ## What did NOT work — DO NOT RETRY THESE
31
+ <!-- Exact failure reasons — do not vague-ify -->
32
+ - **Surface-driving grid GEOMETRY (column count + per-field colSpan) via surface config.** Tried `config.columns` on the surface + `config.colSpan` on elements. Dead end: the resolver's `CONFIG` SurfaceOverride REPLACES an element's whole config blob rather than merging (`_underscore/Model/Core/Surface.php` ~L827: `$config = $value`). So per-client colSpan would force adding a first-class `COL_SPAN` to the `SurfaceOverrides.attribute` ENUM + resolver + schema + framework deploy — far too much machinery for "this box is 2 columns wide". Reverted ALL of it (deleted the columns migration, removed the adapter passthroughs and `--approval-cols` var). Geometry is FE-driven; the 2-col grid is a fixed CSS choice. See KB memory `surface-vs-fe-layout-boundary`.
33
+ - **Stable-sort reorder to float approval-details to the front of the shared summary grid** — superseded and removed by the component split. Net state is the split; do not reintroduce the sort.
34
+
35
+ ## Not tried yet (candidates for next session)
36
+ - Run the two Core migrations (`2026-08-04a`, then `2026-08-04b`) local → beta → prod; add PLAYBOOK change-log + env-status rows; commit dbchanges2 with a `TRUE-#####:` message.
37
+ - QA in the Compass/CompassCanada sandbox: (1) approval-stage order shows the new layout, 2-col field grid, "Approval Details — submitted by {name}" title, and Submitted By no longer in the grid; (2) a non-approval order is visually unchanged.
38
+ - Optional copy tweak: lowercase the mock's "submitted by" by changing `Core.Messages 'salesOrder.approvalDetails.field.submittedBy'` defaultValue to `submitted by` (left as-is — copy decision).
39
+ - Consider whether any approval-stage order can lack `createdByUser._name` (would show Submitted By in neither grid nor title — acceptable but confirm).
40
+
41
+ ## Current file state
42
+ | File | Status | Notes |
43
+ |------|--------|-------|
44
+ | toga25-supply/.../helpers/sectionRenderers.tsx | Created | `createSectionRenderers` factory + `resolvePath`; approvalDetails renders titleSuffix inline |
45
+ | toga25-supply/.../helpers/getVisibleSections.ts | Created | Extracted visibility filter for the standard grid |
46
+ | toga25-supply/.../layoutComponents/SalesOrderApprovalSummaryGrid.tsx | Created | Approval top grid (approval-details span 3 + totals 1) |
47
+ | toga25-supply/.../layoutComponents/SalesOrderSummaryGrid.tsx | Modified | Slimmed to delegate to the two helpers |
48
+ | toga25-supply/.../view/SalesOrderView.tsx | Modified | Branches on approvalActive; Admin Notes at top in approval view |
49
+ | toga25-supply/.../helpers/getDetailSections.tsx | Modified | Approval-only: Order Details + Bill To below items; adminNotes suppressed below |
50
+ | toga25-supply/.../SalesOrderRecordModalLayout/index.ts | Modified | Exports SalesOrderApprovalSummaryGrid |
51
+ | toga25-supply/.../helpers/surfaceBundleToTenantFields.ts | Modified | `titleSuffix` role marker → section.titleSuffix, excluded from grid; geometry passthroughs reverted |
52
+ | toga25-supply/.../SalesOrderRecordModal.module.css | Modified | .approvalSummaryGrid/.approvalDetailsSpan, .approvalFieldsGrid (fixed 2-col), .cardTitleSuffix |
53
+ | dbchanges2/Core/2026-08-04a - ApprovalDetailsSectionTitle.sql | Created | Message "Approval Details" + link surface.titleMessageId (NOT YET RUN) |
54
+ | dbchanges2/Core/2026-08-04b - ApprovalDetailsSubmittedByTitleSuffix.sql | Created | Stamp config.role='titleSuffix' on createdByUser._name element (NOT YET RUN) |
55
+
56
+ ## Decisions made
57
+ - **Component split, not prop-driven**, for approval vs standard — the layouts differ in DOM placement (sections move relative to the Items table), not just CSS. Rejected: one prop-driven `SalesOrderSummaryGrid` (would leak branch logic into SalesOrderView anyway).
58
+ - **Grid geometry lives in the FE; surface owns content/visibility/order/ACL.** Rejected surface-driven columns/colSpan (see failure above). Saved as KB feedback memory `surface-vs-fe-layout-boundary`.
59
+ - **Title suffix via a `config.role` marker**, mirroring the existing `sectionLayoutRule`/`sectionVisibility` markers — surface declares the semantic role + value/label (content), FE renders placement. Rejected: hardcoding the value/label in the FE.
60
+ - **Section title stays surface-driven** (it's content, not geometry) — pure DB change, no FE edit needed.
61
+
62
+ ## Blockers
63
+ None. FE is committed-ready and typechecks; the two Core migrations are authored but NOT yet run against any DB.
64
+
65
+ ## Exact next step
66
+ > Run `dbchanges2/Core/2026-08-04a - ApprovalDetailsSectionTitle.sql` then `2026-08-04b - ApprovalDetailsSubmittedByTitleSuffix.sql` against the LOCAL Core DB, then load an approval-stage order in the Compass sandbox and verify: title reads "Approval Details — submitted by {name}", the field grid is 2-col, and Submitted By is gone from the grid.
67
+
68
+ ---
69
+ _Saved by /session-save on 2026-08-04_
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.520",
3
+ "version": "1.0.522",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",