toga-ai 1.0.429 → 1.0.431
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/api2/INDEX.md +2 -0
- package/knowledge/2.0/apps/api2/features/health-check-endpoint.md +64 -0
- package/knowledge/2.0/apps/api2/features/nested-fk-acl-embedding.md +79 -0
- package/knowledge/2.0/apps/api2/features/v2-api-error-codes.md +18 -2
- package/knowledge/2.0/apps/dbchanges2/INDEX.md +1 -1
- package/knowledge/2.0/apps/dbchanges2/features/surface-layer-schema.md +10 -1
- package/knowledge/2.0/apps/toga25-supply/features/surface-frontend.md +56 -17
- package/package.json +1 -1
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
| [API (api2 / TOGa API v2) Architecture](architecture.md) | `api2` is the backend powering the public **TOGa 2.0 API**. | api2/Controller/Index.php, api2/Component/Api/V2/V2.php, api2/Component/Api/Cxml/Cxml.php, api2/Component/Api/V2/Response/Response.php, api2/Config/ |
|
|
6
6
|
| [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, api2/_.php, _underscore/Model/Cache/Table.php, _underscore/Model/Cache/Tables/Client.php, dbchanges2/Core/2026-06-30b - CacheClusterRegistrationAndRecordTtl.sql, dbchanges2/Cache/2026-06-30a - MultiClientCacheTables.sql |
|
|
7
7
|
| [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 |
|
|
8
|
+
| [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 |
|
|
8
9
|
| [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 |
|
|
10
|
+
| [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 |
|
|
9
11
|
| [Nested-relationship writes & child matching (link vs. create)](features/nested-relationship-writes.md) | When a 2.0 API write payload (`POST`/`PUT`) contains a **nested related object** (e.g. | api2/Component/Api/V2/V2.php |
|
|
10
12
|
| [Record Scripts (computed/aggregate /v2 endpoints — the authoring contract)](features/record-scripts.md) | In api2 you almost never write a controller. | api2/Component/Api/V2/V2.php, _underscore/Model/Team/Sprint.php |
|
|
11
13
|
| [POST + JSON-body args for scripted APIs](features/scripted-api-post-body-args.md) | The V2 engine can run a Record Script (scripted API) for a **POST** request, and a scripted API can receive its arguments from the **JSON request body** instead | api2/Component/Api/V2/V2.php |
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Health-check endpoint (/health liveness short-circuit)"
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: api2
|
|
5
|
+
project: API
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-07-24
|
|
10
|
+
owners: [jcardinal]
|
|
11
|
+
files:
|
|
12
|
+
- api2/Controller/Index.php
|
|
13
|
+
related:
|
|
14
|
+
- ../architecture.md
|
|
15
|
+
- v2-api-error-codes.md
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Summary
|
|
19
|
+
|
|
20
|
+
`_Controller_Index::api()` short-circuits **liveness/health-probe** requests to an HTTP 200
|
|
21
|
+
**before** any routing, DB bootstrap, or V2 engine work runs. EB/LB (Elastic Beanstalk /
|
|
22
|
+
load balancer) probes depend on this always returning 200 cheaply — the architecture doc's
|
|
23
|
+
critical rule is literally "Don't break `/health`". This doc records the **matching contract**
|
|
24
|
+
so a teammate doesn't reintroduce a brittle exact-string check.
|
|
25
|
+
|
|
26
|
+
## How it works
|
|
27
|
+
|
|
28
|
+
The short-circuit lives at the top of `Index.php::api()`, before host dispatch and before the
|
|
29
|
+
Core/Logs DB bootstrap. It matches against a class constant:
|
|
30
|
+
|
|
31
|
+
```php
|
|
32
|
+
const HEALTH_CHECK_ROUTES = ['/health', '/v2/health'];
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The incoming path is **normalized** before comparison — lowercased, query string stripped,
|
|
36
|
+
trailing slash removed — then matched strictly:
|
|
37
|
+
|
|
38
|
+
```php
|
|
39
|
+
$path = strtolower(rtrim(strtok($_SERVER['REQUEST_URI'] ?? '', '?'), '/'));
|
|
40
|
+
if (in_array($path, self::HEALTH_CHECK_ROUTES, true)) { /* return 200 */ }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
This means **all** of these probe variants return 200 and never touch routing or the DB:
|
|
44
|
+
`/health`, `/v2/health`, `/health/` (trailing slash), `/health?x=1` (query string), and any
|
|
45
|
+
case variant. On match it returns 200 with an empty object.
|
|
46
|
+
|
|
47
|
+
## Why the normalization matters (the bug this fixed)
|
|
48
|
+
|
|
49
|
+
The original check was an exact match `$_SERVER['REQUEST_URI'] == '/health'`, so any variant
|
|
50
|
+
(`/v2/health`, trailing slash, query string) **fell through into the V2 routing engine**
|
|
51
|
+
instead of short-circuiting. There it hit auth/DB work and could surface as an **HTTP 500
|
|
52
|
+
(`EO-1`)** — a failing liveness probe. Health probes must degrade to a 200 short-circuit, not
|
|
53
|
+
enter routing. When adding a new probe path, add it to `HEALTH_CHECK_ROUTES` (already
|
|
54
|
+
normalized form: lowercase, no trailing slash, no query) rather than re-adding an exact-string
|
|
55
|
+
branch.
|
|
56
|
+
|
|
57
|
+
## Change history
|
|
58
|
+
- 2026-07-24 — Replaced the brittle exact-string `REQUEST_URI == '/health'` check with a
|
|
59
|
+
`HEALTH_CHECK_ROUTES` constant + normalized (lowercase / strip query / strip trailing slash)
|
|
60
|
+
strict `in_array` match, so `/v2/health`, `/health/`, and `/health?x` all short-circuit to 200
|
|
61
|
+
before routing/DB work instead of falling through to the V2 engine and returning 500 (EO-1).
|
|
62
|
+
Verified via live curl on all variants. (jcardinal)
|
|
63
|
+
</content>
|
|
64
|
+
</invoke>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Nested FK object embedding is gated by the CHILD record's own ACL
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: api2
|
|
5
|
+
project: API
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-07-23
|
|
10
|
+
owners: [apeterson]
|
|
11
|
+
files:
|
|
12
|
+
- api2/Component/Api/V2/V2.php
|
|
13
|
+
- dbchanges2/Client_Compass/2026-07-23b - PurchaseOrdersRecordReadAcl.sql
|
|
14
|
+
related:
|
|
15
|
+
- cross-client-data-retrieval.md
|
|
16
|
+
- tableview-apiwhereclause-row-filtering.md
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## What it is
|
|
20
|
+
|
|
21
|
+
When the V2 JSON engine serializes a foreign-key field into a **nested object** (in
|
|
22
|
+
`getFullModelData()`, ~V2.php L6016-6060), it re-checks the **child** record's own
|
|
23
|
+
`AclRecordPermissions` against the caller's roles before embedding it. If no grant exists, the
|
|
24
|
+
engine records a debug `"No ACL Record Permissions records exist"` and **silently drops the nested
|
|
25
|
+
object** — still HTTP 200, `isSuccess: true`, no error or warning `message`. This is why a linked
|
|
26
|
+
object can silently disappear from an API payload for one client but embed fine for another.
|
|
27
|
+
|
|
28
|
+
## How it works
|
|
29
|
+
|
|
30
|
+
- The gate needs only an `AclRecordPermissions` **row** for the child record on one of the caller's
|
|
31
|
+
roles. Unlike a direct top-level GET of that record, the embedding path does **not** call
|
|
32
|
+
`buildSqlExpression` — an ACL row alone (no logic-group expression) is sufficient to embed.
|
|
33
|
+
- Which roles are checked depends on the child `Records.aclDatabase`. A **CLIENT**-acl record checks
|
|
34
|
+
only the caller's **client** roles; a CORE-acl record checks CORE roles (mirrors the dispatch/record
|
|
35
|
+
rules in [surface-meta-option](surface-meta-option.md)).
|
|
36
|
+
- The drop is **silent by design**: absence of a grant is treated as "not authorized to see this
|
|
37
|
+
linked object", collapsed to omission rather than an error. There is no `messages[]` entry, so the
|
|
38
|
+
symptom presents purely as a missing key in `data`.
|
|
39
|
+
|
|
40
|
+
## The role-3-vs-role-1 mis-seed pattern (the durable gotcha)
|
|
41
|
+
|
|
42
|
+
The most common cause of a silently-missing nested object is an ACL **mis-seed on the wrong role**:
|
|
43
|
+
a child record granted to a service role no human carries (e.g. role **3 "API"**) instead of the
|
|
44
|
+
base role every user carries (role **1 "Base"**). Because a CLIENT-acl record checks only the
|
|
45
|
+
caller's client roles, a human user authenticated with role 1 gets no grant → the child is dropped;
|
|
46
|
+
a machine/API caller on role 3 sees it fine.
|
|
47
|
+
|
|
48
|
+
- **Confirmed instances:** the sales-order↔purchase-order join. On **Compass** the `purchase-orders`
|
|
49
|
+
record (Core Record 17, `_Model_Client_PurchaseOrder`, `aclDatabase=CLIENT`) was granted in
|
|
50
|
+
`Client_Compass` only to role 3, so the nested `purchaseOrder` dropped from the
|
|
51
|
+
sales-order-purchase-orders join response; **Quad** had it on role 1 (perm + logic group → `all`
|
|
52
|
+
expression) and embedded fine. The **same** mis-seed was previously found on **CompassCanada**
|
|
53
|
+
(record 275). It is **not** a single-client quirk — audit every client when a nested object is
|
|
54
|
+
reported missing for some tenants only.
|
|
55
|
+
- **The fix mirrors the working tenant:** add a role-1 `AclRecordPermissions` (full CRUD,
|
|
56
|
+
app-agnostic) + `AclLogicGroups` (AND) + `AclLogicGroupExpressions` reusing the existing `all`
|
|
57
|
+
record expression on the child record. Field-read perms on role 1 usually already exist (no
|
|
58
|
+
`AclFieldPermissions` insert needed). Author it **id-agnostically** (natural keys, not hardcoded
|
|
59
|
+
ids), `NOT EXISTS`-guarded, wrapping any self-referencing subquery in a derived table to avoid MySQL
|
|
60
|
+
error 1093. See `dbchanges2/Client_Compass/2026-07-23b - PurchaseOrdersRecordReadAcl.sql`.
|
|
61
|
+
|
|
62
|
+
## Diagnostic tell
|
|
63
|
+
|
|
64
|
+
A top-level GET of the child record **works** but the **nested embed of the same record under a
|
|
65
|
+
parent is missing**, with a 200/`isSuccess:true` response and no `messages[]`. Chase the child
|
|
66
|
+
record's `AclRecordPermissions` for the caller's role — not the parent's grant, and not field
|
|
67
|
+
permissions.
|
|
68
|
+
|
|
69
|
+
## Change history
|
|
70
|
+
- 2026-07-23 — Documented that V2 nested-FK embedding (`getFullModelData`) re-checks the CHILD
|
|
71
|
+
record's own `AclRecordPermissions` and SILENTLY drops the nested object (200, isSuccess true, no
|
|
72
|
+
message) when no grant exists — needing only an ACL row, not a logic-group expression. Fixed the
|
|
73
|
+
Compass sales-order↔PO join: `purchase-orders` (record 17, CLIENT-acl) was granted only to role 3
|
|
74
|
+
("API") not role 1 ("Base"), so humans lost the nested `purchaseOrder` while Quad (role-1 grant)
|
|
75
|
+
embedded fine; mirrored Quad's role-1 grant (`Client_Compass/2026-07-23b`). Confirmed this is the
|
|
76
|
+
SAME role-3-vs-role-1 mis-seed pattern already seen on CompassCanada (record 275) — not
|
|
77
|
+
CompassCanada-only. (apeterson)
|
|
78
|
+
</content>
|
|
79
|
+
</invoke>
|
|
@@ -6,8 +6,8 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
10
|
-
owners: [mhammontree, tcox]
|
|
9
|
+
updated: 2026-07-24
|
|
10
|
+
owners: [mhammontree, tcox, jcardinal]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
13
13
|
- _underscore/Model/Client/TrackingNumber.php
|
|
@@ -35,6 +35,7 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
|
|
|
35
35
|
| **EV-8** | Request field does not exist | A field sent in the payload isn't registered as a **`Core.RecordFields`** row for that record | Register the field in `Core.RecordFields` (see the "add a field to a V2 record" recipe in the ACL doc) |
|
|
36
36
|
| **EV-9** | No permission to write field | The field exists but the caller's role has no **`AclFieldPermissions`** grant (`isWritable=1`) in the CLIENT DB | Add the `AclFieldPermissions` row for the role (clone a writable sibling field's grant) |
|
|
37
37
|
| **EO-1** | Operation failed — identifier "There is no field called 'X' in the '_Model_Client_Y' model" | The DB column **and** `Core.RecordFields` exist, but the **generated model class** `_underscore/Model/Client/<Name>.php` doesn't declare the field. This is the **4th** requirement beyond the 3-file migration — and most often it's a **cross-repo git branch mismatch** (`_underscore` on a branch whose generated model lacks a field the DB/RecordFields already carry) | Declare the field in the generated model class (`public $field = self::FIELD_*`) and put all related repos (`_underscore`, `api2`, `dbchanges2`, `toga2-supply`) on the **same** feature branch — see the ACL doc's writable-field recipe |
|
|
38
|
+
| **EO-1** | Operation failed — **surfaced from a PHP warning/notice, not a real op error** (e.g. "Attempt to read property 'id' on bool", undefined variable) | A latent PHP warning escalates to a 500 because **Sentry's `ErrorHandler` in api2 promotes warnings/notices into thrown exceptions** (see diagnosis note 5). The known instance: in `V2.php::processRoutePairs()` an **unresolved route** leaves the local `$record = false`, and the post-processing payload interceptor layer then dereferenced `$record->id` → warning → 500 | Guard before dereferencing an unresolved record. The fix added a guard clause `if (!$record) return [$rawRequestedRouteName => $outData];` **before** the interceptor/logging layer (so a bad route returns a clean envelope, not a fatal), and initializes `$record = false;` at the top of the `foreach ($lookupByRouteNames ...)` loop so the invalid-HTTP-method / null-`$action` branch can't leave `$record` undefined (an undefined-variable warning would itself escalate to a 500) |
|
|
38
39
|
| **EZ-1** | Unauthorized record/script dispatch | Missing **`AclRecordScripts`** (scripted APIs) or the **`AclRecordPermissions`** four-table chain (records) for the caller's role | Grant `AclRecordScripts` (scripts) or complete the record-CRUD chain |
|
|
39
40
|
| **EZ-2** | Field-level authorization denied (READ) | The field is registered (`Core.RecordFields` present → no `EV-8`) but the caller's role has no **`AclFieldPermissions`** grant to **read** it. The read-side counterpart of `EV-9` (write) | Add the `AclFieldPermissions` row for the role (`isWritable=0` if the field is server-written). For a **custom** `c_` field use `AclCustomFieldPermissions` instead — see the ACL doc's standard-vs-custom table |
|
|
40
41
|
| **EV-5** | Duplicate `transactionId` | The globally-unique `transactionId` was reused | Send a fresh unique `transactionId` per request |
|
|
@@ -54,6 +55,15 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
|
|
|
54
55
|
3. **EV-6 vs EZ-1** are the two halves of exposing a scripted API: EV-6 = no `Core.RecordScripts`
|
|
55
56
|
route (the segment falls through to record lookup); EZ-1 = the route exists but there's no
|
|
56
57
|
`AclRecordScripts` dispatch grant for the caller's role.
|
|
58
|
+
5. **EO-1 can be a masked PHP warning, not an operation error.** Sentry's `ErrorHandler` in
|
|
59
|
+
api2 **escalates PHP warnings/notices into thrown exceptions**, so a latent "read property on
|
|
60
|
+
bool" or "undefined variable" surfaces to the client as an **HTTP 500 (EO-1)** rather than a
|
|
61
|
+
log line. Practical consequence: in `V2.php` you must guard against warnings as if they were
|
|
62
|
+
fatals — an unresolved route (or a parent FK-field path) leaves `$record = false`, and any
|
|
63
|
+
later `$record->id` deref becomes a 500. Return a clean envelope for unresolved records
|
|
64
|
+
**before** the interceptor/logging layer, and initialize loop-locals so no branch leaves a
|
|
65
|
+
variable undefined.
|
|
66
|
+
|
|
57
67
|
4. **EO-1 ("no field called 'X' in the model") is NOT a DB problem.** EV-8 means the Core
|
|
58
68
|
`RecordFields` registration is missing; EO-1 means the DB column and RecordFields are both
|
|
59
69
|
present but the **generated PHP model class** lacks the field. Before touching migrations,
|
|
@@ -71,6 +81,12 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
|
|
|
71
81
|
|
|
72
82
|
## Change history
|
|
73
83
|
|
|
84
|
+
- 2026-07-24 — Added a second **`EO-1`** case: a latent PHP **warning** (unresolved route →
|
|
85
|
+
`$record->id` on `bool` in `V2.php::processRoutePairs()`, or an undefined loop variable)
|
|
86
|
+
escalates to an HTTP 500 because api2's Sentry `ErrorHandler` promotes warnings/notices into
|
|
87
|
+
exceptions. Fix = guard clause returning a clean envelope for unresolved records before the
|
|
88
|
+
post-processing interceptor layer + initialize `$record = false` at the loop top. Added
|
|
89
|
+
diagnosis note 5 (warnings surface as EO-1 500s). (jcardinal)
|
|
74
90
|
- 2026-07-23 — TRUE-79533: added **`EZ-2`** (field-level READ authorization denied) — the read-side
|
|
75
91
|
counterpart of `EV-9`; a registered field (no `EV-8`) with no `AclFieldPermissions` read grant.
|
|
76
92
|
Clarified EV-8/EV-9/EZ-2 as three states of one field and the standard-vs-custom grant-table
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
| Doc | Summary | Files |
|
|
4
4
|
|-----|---------|-------|
|
|
5
5
|
| [Database Changes (dbchanges2) Repository Architecture](architecture.md) | `dbchanges2` is the **schema-migration / SQL change-set repository** for the entire 2.0 platform. | Core/, Client/, Client_<Tenant>/, Logs/, Logs_Client/, _modules/ |
|
|
6
|
-
| [Surface Layer Schema (UI presentation/config tables)](features/surface-layer-schema.md) | The persistent schema for the platform-wide **Surface** UI presentation/configuration layer (see the `_underscore` [surface-resolver](../../_underscore/features | dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client_Compass/2026-06-25d - SalesOrderSurfaceClientSeed.sql, _underscore/Model/Client/ThemeToken.php, toga25-supply/src/themeConfig.json, dbchanges2/Core/2026-06-25a - SurfaceCoreTables.sql, dbchanges2/Core/2026-06-25b - SurfaceRecordsAndFields.sql, dbchanges2/Core/2026-06-25c - SalesOrderLoginSurfaceSeed.sql, dbchanges2/Core/2026-06-29a - ItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Core/2026-06-29d - VendorItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29e - InventorySurfaceSeed.sql, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Compass/2026-06-30a - SalesOrderDisplaySectionManagerOverrides.sql, dbchanges2/Client_CompassCanada/2026-06-30a - SalesOrderSurfaceManagerOverrides.sql, dbchanges2/Client_Quad/2026-06-30a - SalesOrderSurfaceClientOverrides.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client/2026-06-25b - SurfaceClientSeed.sql, dbchanges2/Client/2026-06-25c - SurfaceClientAcl.sql, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Core/2026-07-17h - Update - ClearApprovalsFilterButtonConfig.sql, dbchanges2/Client_Compass/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Compass/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_Quad/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Quad/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Core/2026-07-20a - Update - HideAdminNotesSectionByDefault.sql, dbchanges2/Core/2026-07-20b - Update - NotesSectionFieldElements.sql, dbchanges2/Client_Compass/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_Compass/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_Quad/2026-07-20a - NotesSectionFieldsOverride.sql, dbchanges2/Core/2026-07-20c - Update - VendorItemsToggleSurfaceSeed.sql, dbchanges2/Client_Compass/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_Compass/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Core/2026-07-20e - RestoreApproveDenyRowActions.sql, dbchanges2/Client_Compass/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Core/2026-07-17 - README - RUN ORDER.md, dbchanges2/Client_Compass/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Compass/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_CompassCanada/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_CompassCanada/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_Quad/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Quad/2026-07-21b - SalesOrderApproveDisabledTooltipTranslation.sql, dbchanges2/Core/2026-07-21a - SalesOrderDecisionSummarySurfaceSeed.sql, dbchanges2/Core/2026-07-21b - SalesOrderDecisionActionSurfaceSeed.sql, dbchanges2/Client_Quad/2026-07-21c - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql |
|
|
6
|
+
| [Surface Layer Schema (UI presentation/config tables)](features/surface-layer-schema.md) | The persistent schema for the platform-wide **Surface** UI presentation/configuration layer (see the `_underscore` [surface-resolver](../../_underscore/features | dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client_Compass/2026-06-25d - SalesOrderSurfaceClientSeed.sql, _underscore/Model/Client/ThemeToken.php, toga25-supply/src/themeConfig.json, dbchanges2/Core/2026-06-25a - SurfaceCoreTables.sql, dbchanges2/Core/2026-06-25b - SurfaceRecordsAndFields.sql, dbchanges2/Core/2026-06-25c - SalesOrderLoginSurfaceSeed.sql, dbchanges2/Core/2026-06-29a - ItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Core/2026-06-29d - VendorItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29e - InventorySurfaceSeed.sql, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Compass/2026-06-30a - SalesOrderDisplaySectionManagerOverrides.sql, dbchanges2/Client_CompassCanada/2026-06-30a - SalesOrderSurfaceManagerOverrides.sql, dbchanges2/Client_Quad/2026-06-30a - SalesOrderSurfaceClientOverrides.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client/2026-06-25b - SurfaceClientSeed.sql, dbchanges2/Client/2026-06-25c - SurfaceClientAcl.sql, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Core/2026-07-17h - Update - ClearApprovalsFilterButtonConfig.sql, dbchanges2/Client_Compass/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Compass/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_Quad/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Quad/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Core/2026-07-20a - Update - HideAdminNotesSectionByDefault.sql, dbchanges2/Core/2026-07-20b - Update - NotesSectionFieldElements.sql, dbchanges2/Client_Compass/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_Compass/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_Quad/2026-07-20a - NotesSectionFieldsOverride.sql, dbchanges2/Core/2026-07-20c - Update - VendorItemsToggleSurfaceSeed.sql, dbchanges2/Client_Compass/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_Compass/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Core/2026-07-20e - RestoreApproveDenyRowActions.sql, dbchanges2/Client_Compass/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Core/2026-07-17 - README - RUN ORDER.md, dbchanges2/Client_Compass/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Compass/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_CompassCanada/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_CompassCanada/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_Quad/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Quad/2026-07-21b - SalesOrderApproveDisabledTooltipTranslation.sql, dbchanges2/Core/2026-07-21a - SalesOrderDecisionSummarySurfaceSeed.sql, dbchanges2/Core/2026-07-21b - SalesOrderDecisionActionSurfaceSeed.sql, dbchanges2/Client_Quad/2026-07-21c - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Core/2026-07-23a - PoNumberDetailFieldValueKey.sql |
|
|
7
7
|
| [2.0 New-Client Onboarding (manual process)](workflows/client-onboarding.md) | > **A local browser wizard now automates this.** Steps 2–9 below (create DBs, generate Core/API > inserts, append to `Clients_Db.txt`) — plus the dbchanges2 bla | Client/, Client_<Tenant>/, Core/, Logs_Client/ |
|
|
@@ -6,7 +6,7 @@ project: Database Changes
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
9
|
+
updated: 2026-07-23
|
|
10
10
|
owners: [jcardinal, apeterson]
|
|
11
11
|
files:
|
|
12
12
|
- dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql
|
|
@@ -65,6 +65,7 @@ files:
|
|
|
65
65
|
- dbchanges2/Client_Quad/2026-07-21c - SalesOrderDecisionSummaryOverride.sql
|
|
66
66
|
- dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql
|
|
67
67
|
- dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql
|
|
68
|
+
- dbchanges2/Core/2026-07-23a - PoNumberDetailFieldValueKey.sql
|
|
68
69
|
related:
|
|
69
70
|
- ../../_underscore/features/surface-resolver.md
|
|
70
71
|
---
|
|
@@ -499,6 +500,14 @@ rule resumes.
|
|
|
499
500
|
override is added). **Open follow-up.**
|
|
500
501
|
|
|
501
502
|
## Change history
|
|
503
|
+
- 2026-07-23 — Repointed the Order Details **"PO Number"** detail field (all clients, Core seed).
|
|
504
|
+
`Core/2026-07-23a - PoNumberDetailFieldValueKey.sql` updates the `SurfaceElements` row (uuid
|
|
505
|
+
`d45c51d8-8211-11f1-bfa7-a30f63c3a801`, label `salesOrder.field.poNumber`) `config` from
|
|
506
|
+
`{"valueKey":"_purchaseOrders","isPersonaValue":true}` to
|
|
507
|
+
`{"valueKey":"purchaseOrderDetails.purchaseOrder.number"}`. The FE DetailSection resolves a field
|
|
508
|
+
value by plain dot-path against the order object, and the real PO number lives at
|
|
509
|
+
`purchaseOrderDetails.purchaseOrder.number`; `isPersonaValue` was dropped (it applies only to
|
|
510
|
+
persona arrays, not scalars). Keyed by stable uuid. (apeterson)
|
|
502
511
|
- 2026-07-21 — Seeded the **per-client decision-summary overrides** off the shared
|
|
503
512
|
`sales-order-decision-summary` surface (all `2026-07-21c`, client-level — `roleId`/`personaId`/
|
|
504
513
|
`languageId` NULL): **Quad** hides three summary rows (`assignedTo._name`, `c_erpEntityId`,
|
|
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
9
|
+
updated: 2026-07-23
|
|
10
10
|
owners: [jcardinal, apeterson]
|
|
11
11
|
files:
|
|
12
12
|
- toga25-supply/src/surface/useFetchSurfaceMeta.ts
|
|
@@ -165,11 +165,37 @@ modal copy, visibility/enable) to Surface; **leave data-fetch wiring (fetch slug
|
|
|
165
165
|
nested-table topology) in JSON/code.** Not every screen reduces to a generic `SurfaceSection` swap —
|
|
166
166
|
see SalesOrders below.
|
|
167
167
|
|
|
168
|
-
##
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
168
|
+
## Surface on/off is 100% BACKEND-driven — no FE roster (2026-07-23)
|
|
169
|
+
|
|
170
|
+
**A client turns Surface on purely by backend state — never a hardcoded FE allowlist.** The
|
|
171
|
+
SalesOrders record modal previously gated surface resolution on an in-code roster
|
|
172
|
+
(`SALES_ORDER_SURFACE_MIGRATED_CLIENTS = ["COMPASS","COMPASSCANADA","QUAD"]`): it fetched the surface
|
|
173
|
+
group only for rostered clients, and everyone else fell back to JSON. That roster was the **last FE
|
|
174
|
+
gate** blocking BE-driven resolution — and it blocked NYCHH from ever receiving a corrected surface
|
|
175
|
+
field even though its API returned the data. **It has been removed entirely.**
|
|
176
|
+
|
|
177
|
+
- The SO modal now **ALWAYS** fetches the surface group and spreads
|
|
178
|
+
`surfaceBundleToTenantFields(surfaces)` **unconditionally** (view-model
|
|
179
|
+
`useSalesOrderRecordModalLayoutModel.tsx`; roster constant deleted from
|
|
180
|
+
`surfaceBundleToTenantFields.ts` and `helpers/index.ts`).
|
|
181
|
+
- Fetch is **failure-isolated** (`retry:false`, errors → `isError`, `surfaces` defaults to `{}`), and
|
|
182
|
+
`surfaceBundleToTenantFields` returns `{}` for an empty/unauthorized bundle — so a client the backend
|
|
183
|
+
does **not** serve stays JSON-driven with no roster. Merge remains **JSON-base + Surface-wins**.
|
|
184
|
+
- A client now "turns on" Surface by two backend facts only: (1) being granted the `surfaces`/`meta-group`
|
|
185
|
+
script ACL (`AclRecordScripts` — see [surface-meta-option](../../api2/features/surface-meta-option.md)),
|
|
186
|
+
**and** (2) having its surfaces seeded/overridden server-side. No FE change is ever needed to onboard a
|
|
187
|
+
client onto Surface.
|
|
188
|
+
- **Audit outcome:** every other `useFetchSurfaceMeta`/`useFetchSurfaceMetaGroup` call site (item-record
|
|
189
|
+
modal, approval-decision modal, Items/VendorItems/Inventory/Login) was already BE-driven — the SO modal
|
|
190
|
+
roster was the ONLY offending gate. The `clientSlug` conditionals in `src/fieldsConfig/` (`resolveRole`,
|
|
191
|
+
`FIELDS[clientSlug]`) are the **legacy JSON fallback layer, NOT surface gating**, and are correctly left
|
|
192
|
+
in place (they serve clients the backend doesn't yet drive via Surface).
|
|
193
|
+
|
|
194
|
+
## SalesOrders record-modal sections — migrated via the registered-renderer seam
|
|
195
|
+
|
|
196
|
+
The SalesOrders detail sections are migrated onto Surface via the registered-renderer approach (not a
|
|
197
|
+
generic `SurfaceSection` swap — `SalesOrderSummaryGrid`'s three bespoke renderers
|
|
198
|
+
`detailSection`/`locationCard`/`totalsCard` are untouched).
|
|
173
199
|
|
|
174
200
|
- **`surfaceBundleToTenantFields()`** (`SalesOrders/helpers/`) adapts the grouped Surface bundle into
|
|
175
201
|
the existing **`TenantFields`** shape, so the existing `SalesOrderSummaryGrid` renderers and
|
|
@@ -178,8 +204,9 @@ three bespoke renderers `detailSection`/`locationCard`/`totalsCard` are untouche
|
|
|
178
204
|
gating are **not** moved. (Registered-renderer seam: *config describes, code decides.*)
|
|
179
205
|
- **Full adapter-seam data path (verified this session, no direct `<SurfaceSection>`):**
|
|
180
206
|
`useFetchSurfaceMetaGroup(SALES_ORDER_SURFACE_SLUGS)` → `surfaceBundleToTenantFields(surfaces)` →
|
|
181
|
-
merged into `tenantFields` (
|
|
182
|
-
|
|
207
|
+
merged into `tenantFields` (**unconditionally, for every client**; an empty/unauthorized bundle
|
|
208
|
+
adapts to `{}` so unseeded clients stay JSON-driven) → **`buildPatchedTenantFields`** (which only
|
|
209
|
+
overrides
|
|
183
210
|
`recordActionFields`, so it **preserves all surface section keys**) → `SalesOrderView` →
|
|
184
211
|
**`SalesOrderSummaryGrid`** (renders by `section.type` / `cardType`:
|
|
185
212
|
`detailSection`/`locationCard`/`totalsCard`, where `cardType` comes from `surface.config.cardType`)
|
|
@@ -212,15 +239,14 @@ from "≥1 field visible", and bind an array-derived value via element `config`
|
|
|
212
239
|
resolved on the FE. This is the presentation counterpart of the schema's field-driven pattern and the
|
|
213
240
|
third section pattern alongside card sections (`cardType` → grid renderer) and marker toggles.
|
|
214
241
|
|
|
215
|
-
- **
|
|
216
|
-
in-code
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
own seeds are verified.
|
|
242
|
+
- **No roster (as of 2026-07-23).** The former
|
|
243
|
+
`SALES_ORDER_SURFACE_MIGRATED_CLIENTS = [COMPASS, COMPASSCANADA, QUAD]` in-code gate was **removed**;
|
|
244
|
+
the view-model now resolves sections from Surface for every client, and an empty/unauthorized bundle
|
|
245
|
+
adapts to `{}` so unseeded clients stay JSON-driven. Merge is still **JSON-base + Surface-wins**. The
|
|
246
|
+
guardrail that used to justify the roster (shared Core SECTION surfaces seeded from Compass's shape)
|
|
247
|
+
is now enforced backend-side by the **Core-neutral-default → per-client opt-in** seeding rule (see
|
|
248
|
+
[surface-layer-schema](../../dbchanges2/features/surface-layer-schema.md)) — Core no longer ships
|
|
249
|
+
Compass's shape as a live default, so removing the roster does not regress unseeded clients.
|
|
224
250
|
- **JSON cleanup:** the migrated section blocks (orderDetails/shipTo/billTo/orderSummary/
|
|
225
251
|
orderRecurring + the 5 display sections) were **deleted** from the COMPASS / COMPASSCANADA / QUAD
|
|
226
252
|
`orderViewFields.json` files. Data-wiring (`salesOrderDetailsConfig`/`itemFulfillmentClickRule`/
|
|
@@ -471,6 +497,19 @@ now carry it (2026-07-21):
|
|
|
471
497
|
treat type-checking as pending. Runtime `GET /v2/surfaces/{slug}/meta` also not yet exercised.
|
|
472
498
|
|
|
473
499
|
## Change history
|
|
500
|
+
- 2026-07-23 — **DECISION + FIX: surface on/off per client is now 100% backend-driven — removed the FE
|
|
501
|
+
roster entirely.** Deleted `SALES_ORDER_SURFACE_MIGRATED_CLIENTS = [COMPASS,COMPASSCANADA,QUAD]` from
|
|
502
|
+
`surfaceBundleToTenantFields.ts` + `helpers/index.ts`; `useSalesOrderRecordModalLayoutModel.tsx` now
|
|
503
|
+
ALWAYS fetches the surface group and spreads `surfaceBundleToTenantFields(surfaces)` unconditionally.
|
|
504
|
+
The fetch is failure-isolated (`retry:false` → `surfaces` defaults to `{}`) and the adapter returns
|
|
505
|
+
`{}` for an empty/unauthorized bundle, so a client the BE doesn't serve stays JSON-driven with no
|
|
506
|
+
roster. A client "turns on" Surface purely via backend state: the `surfaces`/`meta-group`
|
|
507
|
+
`AclRecordScripts` grant + seeded/overridden surfaces. This unblocked NYCHH (was blocked by the roster
|
|
508
|
+
from receiving the corrected PO-number field). Swept every other `useFetchSurfaceMeta(Group)` call
|
|
509
|
+
site (item-record modal, approval-decision modal, Items/VendorItems/Inventory/Login) — all already
|
|
510
|
+
BE-driven; the SO modal roster was the only FE gate. Confirmed the `src/fieldsConfig/` `clientSlug`
|
|
511
|
+
conditionals are the legacy JSON fallback layer (not surface gating) and left them in place.
|
|
512
|
+
(apeterson)
|
|
474
513
|
- 2026-07-21 — Extended `surfaceBundlesToDecisionFields.ts` to emit **`isConcatenated`** on
|
|
475
514
|
decision-summary rows: new exported `DecisionSummaryConcat` type (string shorthand | descriptor
|
|
476
515
|
`{valueKey, prefix?, suffix?, valueType?, layout?, label?}`, mirroring blox `BaseDetailField`); optional
|
package/package.json
CHANGED