toga-ai 1.0.718 → 1.0.719
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/knowledge/1.0/apps/library/features/toga2-api-client-and-bridge.md +24 -1
- package/knowledge/2.0/apps/_underscore/INDEX.md +3 -1
- package/knowledge/2.0/apps/_underscore/features/model-magic-field-access.md +47 -2
- package/knowledge/2.0/apps/_underscore/features/surface-field-recordfield-binding.md +233 -0
- package/knowledge/2.0/apps/_underscore/features/surface-resolver.md +46 -1
- package/knowledge/2.0/apps/_underscore/workflows/recreated-shared-branch-stale-local-remerge.md +141 -0
- package/knowledge/2.0/apps/dbchanges2/INDEX.md +1 -1
- package/knowledge/2.0/apps/dbchanges2/features/surface-layer-schema.md +54 -0
- package/knowledge/2.0/apps/dbchanges2/workflows/framework-branch-ahead-of-schema.md +50 -1
- package/knowledge/2.0/apps/toga-blox/workflows/local-link-into-a-consumer-app.md +65 -0
- package/knowledge/2.0/apps/toga25-supply/features/transfer-orders-page.md +23 -5
- package/knowledge/INDEX.md +2 -2
- package/knowledge/clients/compass-usa/INDEX.md +1 -0
- package/knowledge/clients/compass-usa/features/item-catalogs-and-duplicate-items.md +143 -0
- package/knowledge/clients/compass-usa/features/odp-edi-850-item-resolution.md +131 -24
- package/knowledge/clients/compass-usa/features/odp-edi-file-retention-and-retry.md +25 -4
- package/knowledge/clients/compass-usa/profile.md +11 -1
- package/knowledge/clients/compass-usa/workflows/odp-order-pipeline-to-netsuite.md +23 -1
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ project: Library
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-09-01
|
|
10
10
|
owners: [jcardinal, mhammontree, bala]
|
|
11
11
|
files:
|
|
12
12
|
- library/app/api/toga2.php
|
|
@@ -118,6 +118,23 @@ belong to the NetSuite importer — documented in the per-client-sync doc, not h
|
|
|
118
118
|
- `page`/`recordsPerPage`/`depth`/`calcDepth`/`key` pass through. Pagination is the universal
|
|
119
119
|
`for ($page=1,$nextPage=0; !is_null($nextPage); ++$page)` loop reading `meta->nextPage`.
|
|
120
120
|
|
|
121
|
+
> **⚠ `where` needs the `and` / `or` key even for a SINGLE condition.** A bare list of conditions
|
|
122
|
+
> compiles to something api2 rejects with **HTTP 500, error code `EO-1`, and an empty
|
|
123
|
+
> `transactionId`** — no usable message to work from.
|
|
124
|
+
>
|
|
125
|
+
> ```php
|
|
126
|
+
> // WRONG — 500 / EO-1
|
|
127
|
+
> 'where' => [ ['Items.partNumber' => ['in' => $partNumbers]] ]
|
|
128
|
+
>
|
|
129
|
+
> // CORRECT — the operator key is mandatory at every level
|
|
130
|
+
> 'where' => [ 'and' => [ ['Items.partNumber' => ['in' => $partNumbers]] ] ]
|
|
131
|
+
> ```
|
|
132
|
+
>
|
|
133
|
+
> The realistic way to hit this is **deleting a condition from an existing multi-condition
|
|
134
|
+
> `where`** — the one surviving line looks like it can stand on its own. Cost a deploy cycle in
|
|
135
|
+
> Compass cron 3a on 2026-08-31
|
|
136
|
+
> ([ODP EDI 850 line-item resolution](../../../clients/compass-usa/features/odp-edi-850-item-resolution.md)).
|
|
137
|
+
|
|
121
138
|
### `syncWithToga()` — 2.0 → 1.0 (TOGa)
|
|
122
139
|
|
|
123
140
|
Signature: `(int $togaClientId, string $clientUuid, string $apiKey, string $apiSecret,
|
|
@@ -396,6 +413,12 @@ enable flags** and an optional `$monitorTogadeskDepartmentIds[]`:
|
|
|
396
413
|
|
|
397
414
|
## Change history
|
|
398
415
|
|
|
416
|
+
- 2026-09-01 — Recorded an options-DSL gotcha: a `where` with a **single** condition still
|
|
417
|
+
needs the `'and' =>` (or `'or' =>`) wrapper — a bare list of conditions makes api2 return
|
|
418
|
+
**HTTP 500 `EO-1` with an empty `transactionId`**, no usable message. Most often hit by
|
|
419
|
+
**deleting a condition from an existing multi-condition `where`**, because the one surviving
|
|
420
|
+
line looks like it can stand alone. Cost a deploy cycle in Compass cron 3a on 2026-08-31.
|
|
421
|
+
(bala)
|
|
399
422
|
- 2026-08-29 — The `*FromNetsuite()` importer methods in this class were **completed onto REST** — the
|
|
400
423
|
item-receipt and inventory-adjustment paths dropped their last `App_NetSuite::` SOAP calls in favor of
|
|
401
424
|
`App_Api_Netsuite_Rest::fetch*ById()`, so the togasupply importer now makes **zero SOAP network
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
| [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, toga2-supply/src/pages/Orders/view/OrderView/components/sections/OrderItemsTableSection.tsx, dbchanges2/Core/2026-07-17 - Items isFulfillable RecordField.sql, dbchanges2/Core/2026-07-17 - RegisterItemIsFulfillableInterceptors.sql, dbchanges2/Client/2026-07-17 - ItemsisFulfillable.sql |
|
|
28
28
|
| [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/Canada/SalesOrder.php, _underscore/Model/Compass/SalesOrderStatus.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/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 |
|
|
29
29
|
| [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, test/@Bala/tests/netsuite_salesorder_payload_tests.php |
|
|
30
|
-
| [_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 |
|
|
30
|
+
| [_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, _underscore/Model/Client/RecordFieldSetting.php, _underscore/Model/Client/ClientRecordFieldSetting.php |
|
|
31
31
|
| [_Model::save() parent FK cascade — stored-SQL-field recompute deadlocks](features/model-save-parent-cascade-stored-field-deadlock.md) | `_Model::save()` runs a **generic parent foreign-key cascade**: inserting (or saving) a child row that carries an FK to a parent causes `_Model` to **re-load an | _underscore/Model.php, _underscore/Model/Client/PurchaseOrder.php, _underscore/Model/Client/AdvanceShippingNotice.php |
|
|
32
32
|
| [_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 |
|
|
33
33
|
| [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, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php, _underscore/Trait/Netsuite/SalesOrder.php, worker2/Worker/Netsuite/SalesOrder.php, worker2/Component/Forecast/SaleImport/SaleImport.php, worker2/Worker/Netsuite/Opportunity.php |
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
| [SO↔PO bridge tables are TWO tables in OPPOSITE directions (upstream vs downstream)](features/sales-order-purchase-order-bridge-direction.md) | There are **two** bridge tables linking sales orders and purchase orders, and they mean **opposite things**. | _underscore/Model/Client/PurchaseOrders/SalesOrder.php, _underscore/Model/Client/SalesOrders/PurchaseOrder.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/SalesOrder.php, _underscore/Trait/Netsuite/SalesOrder.php, library/app/api/toga2.php |
|
|
43
43
|
| [Per-client sales-order status filter (Surface FILTER_SET → table meta `filterOptions`)](features/sales-order-status-filter-surface.md) | The status-filter dropdown on the sales-orders table is **per client**, driven by a Surface `FILTER_SET` rather than by the raw contents of the client's `SalesO | _underscore/Model/Client/TableView.php, _underscore/Model/Core/Surface.php, _underscore/Model/Client/SalesOrder.php, _underscore/Model/Quad/SalesOrder.php, _underscore/Model/Prudential/SalesOrder.php, toga-blox/src/components/Table/hooks/useFetchTablePageMeta.ts, toga-blox/src/api/types.ts, dbchanges2/Core/2026-08-24b - SalesOrderStatusFilterSurfaceSeed.sql, dbchanges2/Client_Compass/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_CompassCanada/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_Quad/2026-08-24b - SalesOrderStatusFilterHides.sql, dbchanges2/Client_Nychh/2026-08-24b - SalesOrderStatusFilterHides.sql |
|
|
44
44
|
| [_String helpers — ASCII-safe HTML entity encoding (and the parseBetween trap)](features/string-html-entity-helpers.md) | `_String` is the 2.0 framework's static string utility class. | _underscore/String.php |
|
|
45
|
+
| [Surface FIELD ↔ RecordField binding (maxLength / validationType / isWritable fold-in)](features/surface-field-recordfield-binding.md) | A resolver capability that makes a surface `FIELD` element carry its **input contract** — max length, validator name, and whether the caller may write it — inst | _underscore/Model/Core/Surface.php, _underscore/Model/Core/RecordField.php, _underscore/Model/Core/AclFieldPermission.php, _underscore/Model/Client/RecordFieldSetting.php, _underscore/Model/Client/ClientRecordFieldSetting.php, dbchanges2/Core/2026-08-14c - RecordFieldsValidationMetadata.sql, dbchanges2/Client/2026-08-14a - RecordFieldSettingsClientTable.sql, api2/Component/Api/V2/V2.php |
|
|
45
46
|
| [Surface Resolver (_Model_Core_Surface::resolve — replaces Page::meta)](features/surface-resolver.md) | The runtime for the platform-wide **Surface** UI presentation layer: 9 `_underscore` models plus a cached resolver, `_Model_Core_Surface::resolve(&$api, string | _underscore/Model/Client/Language.php, dbchanges2/Core/2026-08-21 - SalesOrderDecisionSurfacesReseed.sql, dbchanges2/Core/2026-08-24 - RestoreApproveDenyRowActionsVisibility.sql, dbchanges2/Client_Quad/2026-08-24 - ProdPortApprovePoNumberEnabledRule.sql, dbchanges2/Client_Compass/2026-08-24 - ProdPortApprovalsGateAndApproveStepTwoRule.sql, dbchanges2/Client_CompassCanada/2026-08-24 - ProdPortApprovalsGateAndApproveStepTwoRule.sql, dbchanges2/Client_Nychh/2026-08-24 - FixInventoryGroupingsUnitsTopologyOverrideIds.sql, toga25-supply/src/App.tsx, toga25-supply/src/surface/useFetchSurfaceMeta.ts, toga25-supply/src/contexts/AuthContext.tsx, dbchanges2/Core/2026-07-21a - SalesOrderDecisionSummarySurfaceSeed.sql, dbchanges2/Core/2026-07-21b - SalesOrderDecisionActionSurfaceSeed.sql, dbchanges2/Core/2026-08-21a - SalesOrderDecisionSurfacesReseed.sql, dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_Quad/2026-07-21c - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_Compass/2026-08-04a - ApprovalDetailsAssignedManagerPreferredStage.sql, _underscore/Model/Core/Surface.php, _underscore/Model/Client/AclRecordScript.php, _underscore/Model/Core/RecordScript.php, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Quad/2026-07-01a - GrantSurfacesMetaGroupScriptAcl.sql, dbchanges2/Client_CompassCanada/2026-07-01a - GrantSurfacesMetaGroupScriptAcl.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Client_Compass/2026-07-15f - SalesOrderRecordActionsRemoveDeadConfigRuleOverrides.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, _underscore/Model/Core/SurfaceElement.php, _underscore/Model/Core/Action.php, _underscore/Model/Core/Vocabulary.php, _underscore/Model/Core/VocabularyTerm.php, _underscore/Model/Core/Message.php, _underscore/Model/Client/SurfaceOverride.php, _underscore/Model/Client/MessageTranslation.php, _underscore/Model/Client/ThemeToken.php, _underscore/Model/Core/Page.php, api2/Component/Api/V2/V2.php, dbchanges2/Core/2026-08-12a - NavigationSurfaceSeed.sql, dbchanges2/Client_Compass/2026-08-12a - NavigationRoleGrants.sql, toga25-supply/surface-layer-guide.md |
|
|
46
47
|
| [Table-View Hyperlink Columns (meta → ACL → computed URL → render)](features/tableview-hyperlink-columns.md) | Any 2.0 table-view column can render its value as a clickable link instead of plain text. | _underscore/Model/Client/TableView.php, _underscore/Model/Client/TrackingNumber.php, api2/Component/Api/V2/V2.php, toga2-supply/src/api/toga.ts, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/formatTableData.tsx, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/convertData.tsx, toga2-supply/src/components/ui/Tables/hooks/useDataTableState.tsx, dbchanges2/Client/2026-07-20 - TrackingNumberHyperlinkAndFieldPermission.sql |
|
|
47
48
|
| [TableView joins (TableViewJoins → SQL) — aliasing, chained multi-hop joins, ACL](features/tableview-joins.md) | `Client_*.TableViewJoins` rows are what let a table view show a column from a table other than its base record. | _underscore/Model/Client/TableView.php, _underscore/Model.php, api2/Component/Api/V2/V2.php, dbchanges2/Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql, dbchanges2/Client_Elite/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_CompassCanada/2026-08-27a - ItemFulfillmentTableViewsRebuild.sql, dbchanges2/Client_Nychh/2026-08-28a - TransferOrdersTableView.sql |
|
|
@@ -50,4 +51,5 @@
|
|
|
50
51
|
| [Units for Items for Purchase Orders — Data Structure](features/units-for-items-for-purchase-orders.md) | Describes how unit (serialized inventory) data is linked to sales-order and purchase-order line items behind the `units-for-items-for-purchase-orders` TableView | _underscore/Model/Client/ServiceRequest.php, dbchanges2/Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql |
|
|
51
52
|
| [USPS DPV Deliverability Verdict (is this address actually insurable/shippable?)](features/usps-dpv-deliverability.md) | **USPS returning HTTP 200 with a populated address is NOT evidence that the address is deliverable.** The authoritative signal is USPS's **DPV (Delivery Point V | _underscore/Component/Library/Carriers/Usps/Usps.php, _underscore/Model/Client/Address.php, _underscore/Model/Rate/Entitlement.php |
|
|
52
53
|
| [Refreshing a Local Dev Database from Beta (dev-sandbox)](workflows/local-db-refresh-from-beta.md) | How to reset a local 2.0 dev database from the **beta / dev-sandbox** environment: dump each schema (`Core`, `Client_<Id>`, `Logs_<Id>`, …) from the beta host, | api2/Config/, _underscore/Loader.php, _underscore/Model/Client/BundleTranslation.php, api2/Component/Api/V2/V2.php, toga25-supply/sync_compasscanada_schema.sql |
|
|
54
|
+
| [Deleting a shared branch does not remove bad commits — a stale local clone merges them back](workflows/recreated-shared-branch-stale-local-remerge.md) | **Deleting and recreating a shared environment branch removes only the *ref*.** Every teammate who still has that branch checked out locally keeps the full pre- | _underscore/Model/Core/Surface.php, _underscore/Model/Client/RecordFieldSetting.php, api2/.platform/hooks/prebuild/git.sh, api2/.ebextensions/git.php |
|
|
53
55
|
| [Running a 2.0 App Locally (browser, end-to-end via api2)](workflows/running-a-2.0-app-locally.md) | The full dependency chain required to run a 2.0 client app **through the browser**, end-to-end, against a **local `api2`** (e.g. | _underscore/Environment.php, _underscore/Config.php, _underscore/Database.php, _underscore/Route.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php, api2/index.php, api2/.htaccess |
|
|
@@ -6,15 +6,19 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: ["jcardinal", "mhammontree"]
|
|
9
|
+
updated: 2026-09-01
|
|
10
|
+
owners: ["jcardinal", "mhammontree", "apeterson"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Core/Model.php
|
|
13
13
|
- _underscore/Model.php
|
|
14
14
|
- _underscore/Model/Rate/Subscription.php
|
|
15
|
+
- _underscore/Model/Client/RecordFieldSetting.php
|
|
16
|
+
- _underscore/Model/Client/ClientRecordFieldSetting.php
|
|
15
17
|
related:
|
|
16
18
|
- ../../api2/features/language-translation-layer.md
|
|
17
19
|
- ./model-save-vs-query-atomic-update.md
|
|
20
|
+
- ./surface-field-recordfield-binding.md
|
|
21
|
+
- ../../dbchanges2/workflows/framework-branch-ahead-of-schema.md
|
|
18
22
|
- ../../../../clients/rate/features/subscription-cancellation.md
|
|
19
23
|
---
|
|
20
24
|
|
|
@@ -59,6 +63,38 @@ $isRequested = trim((string) $subscription->dtRequestToCancel) !== '';
|
|
|
59
63
|
`_Model` declares `__get`/`__set` only (`_underscore/Model.php:464,481`) — there is no `__isset` on
|
|
60
64
|
the base `_Model` either, so this applies to **every** 2.0 model, not just the Core one.
|
|
61
65
|
|
|
66
|
+
## Triage rule — "There is no field called 'X' in the '_Model_…' model" is ALWAYS an undeclared non-`c_` column
|
|
67
|
+
|
|
68
|
+
A **500 `EO-1`** whose `identifiers.message` reads:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
There is no field called '<column>' in the '<_Model_...>' model.
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
is a `_Model::__get()` **throw**, not a database error. It means code read a property the model
|
|
75
|
+
class does not declare. Two things follow immediately, and they cut the search space hard:
|
|
76
|
+
|
|
77
|
+
1. **It is never a schema problem.** No SQL ran. Do not check `information_schema`, do not suspect a
|
|
78
|
+
missing migration on *that* column — check the model class.
|
|
79
|
+
2. **The column is never `c_`-prefixed.** Custom `c_` columns are framework-dynamic and need no
|
|
80
|
+
declaration, so this error can only ever fire for a **standard** column. The fix is binary:
|
|
81
|
+
declare the property on the model, or stop reading it.
|
|
82
|
+
|
|
83
|
+
**The trap that produces it: two similarly-named models, one of which lacks the column.** Swapping
|
|
84
|
+
one for another at a call site **lints clean and only dies at runtime** — nothing in PHP or CI
|
|
85
|
+
flags it. Concrete (2026-09-01): a surface call site was repointed from
|
|
86
|
+
`_Model_Client_RecordFieldSetting` (table `RecordFieldSettings`, declares `maxLength`) to
|
|
87
|
+
`_Model_Client_ClientRecordFieldSetting` (legacy EAV table `ClientRecordFieldSettings`, no
|
|
88
|
+
`maxLength`), producing exactly this message and 500ing every RecordField-bound surface field. See
|
|
89
|
+
[surface FIELD ↔ RecordField binding](surface-field-recordfield-binding.md).
|
|
90
|
+
|
|
91
|
+
**Contrast with the other failure shape.** A column declared on the model but **absent from the
|
|
92
|
+
database** fails as MySQL **1054 "Unknown column"** on the model's own `SELECT`, because
|
|
93
|
+
`_Model::initialize()` selects every declared property. So the two messages tell you which side is
|
|
94
|
+
wrong: **1054** = the database is behind the model (see
|
|
95
|
+
[framework branch ahead of schema](../../dbchanges2/workflows/framework-branch-ahead-of-schema.md));
|
|
96
|
+
**"no field called"** = the call site is reading something the model never declared.
|
|
97
|
+
|
|
62
98
|
## Gotchas / known issues
|
|
63
99
|
|
|
64
100
|
- **Treat `empty()` / `isset()` / `??` on a magic field as simply UNRELIABLE — the exact behavior
|
|
@@ -91,6 +127,15 @@ the base `_Model` either, so this applies to **every** 2.0 model, not just the C
|
|
|
91
127
|
|
|
92
128
|
## Change history
|
|
93
129
|
|
|
130
|
+
- 2026-09-01 — Added the **triage rule** for the other half of this gap (no code changed): a 500
|
|
131
|
+
`EO-1` reading *"There is no field called 'X' in the '_Model_…' model"* is a `__get` throw, so it
|
|
132
|
+
is **never** a schema problem and the column is **never** `c_`-prefixed (custom fields need no
|
|
133
|
+
declaration) — fix the model declaration or the read. Recorded the trap that produces it: swapping
|
|
134
|
+
two similarly-named models at one call site **lints clean and only fails at runtime** — verified on
|
|
135
|
+
`_Model_Client_RecordFieldSetting` (declares `maxLength`) vs the legacy EAV
|
|
136
|
+
`_Model_Client_ClientRecordFieldSetting` (does not), which 500'd every RecordField-bound surface
|
|
137
|
+
field. Also drew the contrast that identifies the opposite direction: MySQL **1054** means the
|
|
138
|
+
database is behind the model, because `initialize()` selects every declared property. (apeterson)
|
|
94
139
|
- 2026-08-06 — TRUE-80282 testing: confirmed the gap exists on the **base `_Model`** too (it
|
|
95
140
|
declares `__get`/`__set` only, `_underscore/Model.php:464,481`), so this applies to every 2.0
|
|
96
141
|
model. Recorded the sharpest empirical case yet: `$s->dateCancelled` returns `'2026-08-26'` while
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Surface FIELD ↔ RecordField binding (maxLength / validationType / isWritable fold-in)"
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: _underscore
|
|
5
|
+
project: _Underscore
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-09-01
|
|
10
|
+
owners: [apeterson, jcardinal]
|
|
11
|
+
files:
|
|
12
|
+
- _underscore/Model/Core/Surface.php
|
|
13
|
+
- _underscore/Model/Core/RecordField.php
|
|
14
|
+
- _underscore/Model/Core/AclFieldPermission.php
|
|
15
|
+
- _underscore/Model/Client/RecordFieldSetting.php
|
|
16
|
+
- _underscore/Model/Client/ClientRecordFieldSetting.php
|
|
17
|
+
- dbchanges2/Core/2026-08-14c - RecordFieldsValidationMetadata.sql
|
|
18
|
+
- dbchanges2/Client/2026-08-14a - RecordFieldSettingsClientTable.sql
|
|
19
|
+
- api2/Component/Api/V2/V2.php
|
|
20
|
+
related:
|
|
21
|
+
- surface-resolver.md
|
|
22
|
+
- acl-permission-chain.md
|
|
23
|
+
- model-magic-field-access.md
|
|
24
|
+
- ../../dbchanges2/workflows/framework-branch-ahead-of-schema.md
|
|
25
|
+
- ../../dbchanges2/features/surface-layer-schema.md
|
|
26
|
+
- ../../api2/features/environment-variable-drives-underscore-branch.md
|
|
27
|
+
- ../workflows/recreated-shared-branch-stale-local-remerge.md
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## What it is
|
|
31
|
+
|
|
32
|
+
A resolver capability that makes a surface `FIELD` element carry its **input contract** — max
|
|
33
|
+
length, validator name, and whether the caller may write it — instead of the front end guessing.
|
|
34
|
+
When a resolved `SurfaceElement` has `renderType = FIELD` **and** a non-null `recordFieldId`, the
|
|
35
|
+
resolver folds `Core.RecordFields` metadata plus the field-level ACL write gate onto the element
|
|
36
|
+
before it goes in the bundle.
|
|
37
|
+
|
|
38
|
+
It lives in `_Model_Core_Surface::_resolveRecordFieldBinding()` and its helper
|
|
39
|
+
`_resolveFieldWritableMap()`. It is an addition to `resolve()`/`_buildBundle()` — see
|
|
40
|
+
[surface-resolver](surface-resolver.md) for the rest of the pipeline.
|
|
41
|
+
|
|
42
|
+
> ## ⚠ Read this before you grep — this doc describes code that ships on TWO feature branches only
|
|
43
|
+
>
|
|
44
|
+
> **`_resolveRecordFieldBinding` is NOT on `_production`, `_beta`, `_sandbox-client` or
|
|
45
|
+
> `_sandbox-dev`.** Verified 2026-09-01 against live remote refs:
|
|
46
|
+
>
|
|
47
|
+
> | Branch | hits |
|
|
48
|
+
> |---|---|
|
|
49
|
+
> | `origin/toga25-desk` | **3** |
|
|
50
|
+
> | `origin/_qa-alpha` | **3** |
|
|
51
|
+
> | `origin/_sandbox-client` | 0 |
|
|
52
|
+
> | `origin/_production` | 0 |
|
|
53
|
+
> | `origin/_beta` | 0 |
|
|
54
|
+
>
|
|
55
|
+
> `origin/_sandbox-client` and `origin/_production` are both at `91ce7462` and byte-identical.
|
|
56
|
+
> **Nothing in this doc is live in production.** It was briefly on `_sandbox-client` (as
|
|
57
|
+
> `fefeebd7`) and was rolled back on 2026-09-01 — see the incident gotcha below.
|
|
58
|
+
>
|
|
59
|
+
> **So a grep of `_sandbox-client` or `_production` returns nothing, and that is expected — it is not
|
|
60
|
+
> evidence this doc is fiction.** This is the same hazard as the surface-resolver gotcha *"'grep says
|
|
61
|
+
> this function doesn't exist' is meaningless unless you state which `_underscore` branch is checked
|
|
62
|
+
> out"* ([surface-resolver](surface-resolver.md) → Gotchas). Always name the branch. See also
|
|
63
|
+
> [ENVIRONMENT decides the `_underscore`
|
|
64
|
+
> branch](../../api2/features/environment-variable-drives-underscore-branch.md).
|
|
65
|
+
|
|
66
|
+
## What gets folded onto the element
|
|
67
|
+
|
|
68
|
+
| Emitted key | Source |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `maxLength` | 3-level cascade: `element.characterLimit` ?? client override (`Client.RecordFieldSettings.maxLength`) ?? `Core.RecordFields.maxLength` |
|
|
71
|
+
| `validationType` | `element.validationType` ?? `Core.RecordFields.validationType` |
|
|
72
|
+
| `showRequiredIndicator`, `inputType`, `isSearchable` | straight off the `SurfaceElements` columns |
|
|
73
|
+
| `isWritable` | the per-record field-permission map (see below) |
|
|
74
|
+
|
|
75
|
+
Every other element (`TEXT`, `BUTTON`, …), and any `FIELD` element with a null `recordFieldId`, is
|
|
76
|
+
untouched. The presence gate is the whole trigger — and also the whole blast radius (see gotchas).
|
|
77
|
+
|
|
78
|
+
## Drop semantics — it never fabricates a permission and never silently hides an unjudged field
|
|
79
|
+
|
|
80
|
+
This is the part worth knowing, because it is deliberately asymmetric:
|
|
81
|
+
|
|
82
|
+
- A permission map **was** resolved and the field has **no entry** in it → the element is
|
|
83
|
+
**dropped**. Presence in the map means readable (`AclFieldPermissions` has no `isReadable`
|
|
84
|
+
column — row presence grants READ, `isWritable` additionally grants WRITE; see
|
|
85
|
+
[acl-permission-chain](acl-permission-chain.md)).
|
|
86
|
+
- **No map could be resolved** (e.g. `$api` is not the V2 engine) → the element is **kept** and
|
|
87
|
+
`isWritable` is simply **omitted**. It does not invent `true`, and it does not hide a field whose
|
|
88
|
+
permissions were never adjudicated.
|
|
89
|
+
- **Dangling `recordFieldId`** (the `RecordField` row is gone) → `error_log`'d and the element is
|
|
90
|
+
**kept unchanged**. Same soft-FK tolerance as the rest of the resolver.
|
|
91
|
+
|
|
92
|
+
## Role-id space and caching
|
|
93
|
+
|
|
94
|
+
`_resolveFieldWritableMap()` picks the role-id space **off the record**, exactly as
|
|
95
|
+
`V2::processRoutePairs` does: `Core.Records.aclDatabase = CORE` → `id.core.roles`, otherwise
|
|
96
|
+
`id.client.roles`. It then calls `$api->getAclFieldPermissions()`, which delegates to the one shared
|
|
97
|
+
transformer `_Model_Core_AclFieldPermission::resolveFieldPermissions()` — there is no second copy of
|
|
98
|
+
the ACL logic here.
|
|
99
|
+
|
|
100
|
+
Two per-request caches keep it cheap:
|
|
101
|
+
|
|
102
|
+
- `$recordFieldCache` — keyed by `recordFieldId`.
|
|
103
|
+
- `$writableMapByRecordId` — the map is computed **once per record**, so N fields of one record cost
|
|
104
|
+
**one** ACL lookup, not N.
|
|
105
|
+
|
|
106
|
+
## Schema it depends on (both migrations are STRANDED, not merged)
|
|
107
|
+
|
|
108
|
+
| Object | Migration | State |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `Core.RecordFields.maxLength` + `validationType` | `Core/2026-08-14c - RecordFieldsValidationMetadata.sql` | on `origin/toga25-desk`, **not merged to `_main`**; hand-applied to `sandbox-client` 2026-08-27 |
|
|
111
|
+
| `Client.RecordFieldSettings` (new table, `recordFieldId` + `maxLength`) | `Client/2026-08-14a - RecordFieldSettingsClientTable.sql` | on `origin/toga25-desk`, **not merged to `_main`**, applied **nowhere** |
|
|
112
|
+
|
|
113
|
+
`Client.RecordFieldSettings` is a purpose-built typed override table — a single `maxLength` column,
|
|
114
|
+
`UNIQUE(recordFieldId)`, ships empty, and `recordFieldId` is a **soft** cross-cluster integer ref to
|
|
115
|
+
`Core.RecordFields` (Core and client DBs are separate production clusters, so no FK is possible).
|
|
116
|
+
That is correct by the isolation rule, not an oversight. Full drift procedure and inventory:
|
|
117
|
+
[framework branch ahead of schema](../../dbchanges2/workflows/framework-branch-ahead-of-schema.md).
|
|
118
|
+
|
|
119
|
+
**Consequence for the front end:** `toga25-supply` does **not** consume `maxLength`,
|
|
120
|
+
`validationType` or `isWritable` yet. The backend emission is ahead of the FE.
|
|
121
|
+
|
|
122
|
+
## Gotchas / known issues
|
|
123
|
+
|
|
124
|
+
- **🚨 The client-override tier is easy to point at the WRONG model, and the wrong one 500s the
|
|
125
|
+
whole screen.** There are two similarly-named client models and only one has a `maxLength`:
|
|
126
|
+
|
|
127
|
+
| Model | `const TABLE` | Shape | Has `maxLength`? |
|
|
128
|
+
|---|---|---|---|
|
|
129
|
+
| `_Model_Client_RecordFieldSetting` | `RecordFieldSettings` | purpose-built typed override | **yes** |
|
|
130
|
+
| `_Model_Client_ClientRecordFieldSetting` | `ClientRecordFieldSettings` | legacy EAV (`settingId` + `value`) | **no** |
|
|
131
|
+
|
|
132
|
+
The legacy one declares only `id`/`uuid`/`recordFieldId`/`contextRecordFieldId`/`settingId`/
|
|
133
|
+
`isOverridable`/`value` — it is part of the old `*Settings` EAV cascade behind
|
|
134
|
+
[page-meta-context-field-settings](page-meta-context-field-settings.md). Reading `->maxLength` off
|
|
135
|
+
it **throws**, because `maxLength` is not `c_`-prefixed and so needs a declared property (see
|
|
136
|
+
[model-magic-field-access](model-magic-field-access.md)).
|
|
137
|
+
|
|
138
|
+
**Incident (`_sandbox-client`, 2026-08-31 → 2026-09-01 — RESOLVED by a branch rollback).**
|
|
139
|
+
`GET /v2/surfaces/meta-group` returned **500 `EO-1`** with
|
|
140
|
+
`identifiers.message` = *"There is no field called 'maxLength' in the
|
|
141
|
+
'_Model_Client_ClientRecordFieldSetting' model."* Trace: `_Model::__get` ←
|
|
142
|
+
`_resolveRecordFieldBinding` ← `_buildBundle` ← `resolve` ← `metaGroup` (`V2.php:4458`).
|
|
143
|
+
A commit pair had **deleted** `Model/Client/RecordFieldSetting.php` (`66022382`) and repointed the
|
|
144
|
+
call site at the legacy `ClientRecordFieldSetting` (`64466ca2`) — swapping a "table doesn't exist"
|
|
145
|
+
500 for a `__get` 500. It lints clean; it only dies at runtime.
|
|
146
|
+
|
|
147
|
+
Branch timeline: recreated clean at `2de6815b` (Aug 31 09:17) → **re-polluted** by merge
|
|
148
|
+
`a57f13fd` (Aug 31 18:20, parents `64466ca2` + `2de6815b` — a teammate's stale local clone) →
|
|
149
|
+
`fefeebd7`, which the deploy picked up → **rolled back to `_production` at `91ce7462`**
|
|
150
|
+
(2026-09-01). Full mechanism and triage:
|
|
151
|
+
[recreated shared branch / stale-local re-merge](../workflows/recreated-shared-branch-stale-local-remerge.md).
|
|
152
|
+
|
|
153
|
+
Two things follow. **(a)** The bad swap is on **no remote branch** today — it survives only in
|
|
154
|
+
local clones, so the re-merge vector is still live. **(b)** The rollback was indiscriminate: it
|
|
155
|
+
also removed the **good** half, which is why `_sandbox-client` has none of this feature now. The
|
|
156
|
+
canonical code on `origin/toga25-desk` / `origin/_qa-alpha` uses the **correct**
|
|
157
|
+
`_Model_Client_RecordFieldSetting`.
|
|
158
|
+
|
|
159
|
+
**Do not "fix" this by deleting the client-override tier.** The tier has a designed table and a
|
|
160
|
+
written migration (above) — it is **unapplied, not imaginary**. That was the session's initial
|
|
161
|
+
recommendation and it was **wrong**, because the branch search that produced it excluded
|
|
162
|
+
`origin/toga25-desk`. Fix the schema instead — see *Forward path* below.
|
|
163
|
+
|
|
164
|
+
- **⚠ One bad FIELD element takes down a WHOLE SCREEN, not one control.** The binding is gated only
|
|
165
|
+
on `renderType = FIELD && recordFieldId`, so it runs for **every** RecordField-bound field element.
|
|
166
|
+
And because `metaGroup` resolves **N section surfaces in one call**, a single throwing element
|
|
167
|
+
fails the entire response — the front end reports it as a generic *"network error"*, which reads
|
|
168
|
+
like an outage rather than a data problem. When a whole 2.5 screen dies at once, suspect one shared
|
|
169
|
+
element, not the screen.
|
|
170
|
+
|
|
171
|
+
- **A missing `Core.RecordFields` column fails EARLIER and differently.** In `sandbox-client` the
|
|
172
|
+
`new _Model_Core_RecordField($id)` load **succeeded**, which is what proved `maxLength` /
|
|
173
|
+
`validationType` were physically present there (they were hand-applied 2026-08-27) — a missing
|
|
174
|
+
column would have thrown MySQL **1054** on the model `SELECT` first, because
|
|
175
|
+
`_Model::initialize()` selects every declared property. So the two failure shapes tell you which
|
|
176
|
+
layer is broken: **1054 on the RecordField SELECT** = Core schema behind the model;
|
|
177
|
+
**`__get` throw** = call site reading an undeclared property. A freshly provisioned environment
|
|
178
|
+
gets the 1054 form, not the `__get` form.
|
|
179
|
+
|
|
180
|
+
- Confirm the Core columns in any environment with:
|
|
181
|
+
```sql
|
|
182
|
+
SELECT COLUMN_NAME FROM information_schema.COLUMNS
|
|
183
|
+
WHERE TABLE_SCHEMA = 'Core' AND TABLE_NAME = 'RecordFields'
|
|
184
|
+
AND COLUMN_NAME IN ('maxLength', 'validationType');
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Forward path for whoever lands this feature
|
|
188
|
+
|
|
189
|
+
1. **Keep `_Model_Client_RecordFieldSetting`.** Do not repoint it at the legacy EAV model, and do not
|
|
190
|
+
delete the client-override tier.
|
|
191
|
+
2. **Merge the two stranded `2026-08-14` migrations together with the code** —
|
|
192
|
+
`Core/2026-08-14c - RecordFieldsValidationMetadata.sql` and
|
|
193
|
+
`Client/2026-08-14a - RecordFieldSettingsClientTable.sql`, both on `origin/toga25-desk`. Code
|
|
194
|
+
without them reproduces the 500; the migrations without the code are inert and harmless, so they
|
|
195
|
+
are safe to land first.
|
|
196
|
+
3. **Remember `Client/` is a fan-out** — `RecordFieldSettings` is one `CREATE TABLE` **per tenant
|
|
197
|
+
database** (~30), plus the blank client template so newly provisioned clients get it. `Core` is a
|
|
198
|
+
single `ALTER`.
|
|
199
|
+
4. **⚠ `sandbox-client`'s Core schema is AHEAD of its own repo state.** `Core.RecordFields.maxLength`
|
|
200
|
+
and `validationType` were **hand-applied** there on 2026-08-27 (recorded verbatim in
|
|
201
|
+
[framework branch ahead of schema](../../dbchanges2/workflows/framework-branch-ahead-of-schema.md)),
|
|
202
|
+
so the real migration will run against at least one database that already has the columns —
|
|
203
|
+
**idempotent by requirement, not by preference.** `Client/2026-08-14a` already uses
|
|
204
|
+
`CREATE TABLE IF NOT EXISTS`.
|
|
205
|
+
5. **Sweep before deploying.** Moving a tier onto a branch that carries this feature is a schema
|
|
206
|
+
event; run the `origin/_production..origin/<env-branch> -- Model/` sweep first.
|
|
207
|
+
|
|
208
|
+
## Change history
|
|
209
|
+
|
|
210
|
+
- 2026-09-01 — Documented the capability from a production-diagnosis session (no code changed).
|
|
211
|
+
Recorded the fold-in contract (`maxLength` 3-level cascade / `validationType` / `isWritable` /
|
|
212
|
+
`showRequiredIndicator` / `inputType` / `isSearchable`), the asymmetric drop semantics (drop only
|
|
213
|
+
when a map exists and the field is absent; keep-and-omit when no map resolves; keep + `error_log`
|
|
214
|
+
on a dangling `recordFieldId`), the record-driven role-id space, and the two per-request caches.
|
|
215
|
+
Diagnosed the **500 `EO-1` "no field called 'maxLength' in
|
|
216
|
+
`_Model_Client_ClientRecordFieldSetting`"** crash: the call site had been repointed from the
|
|
217
|
+
purpose-built `_Model_Client_RecordFieldSetting` (`RecordFieldSettings`, declares `maxLength`) to
|
|
218
|
+
the legacy EAV `ClientRecordFieldSettings` (no such column). **Corrected the session's own
|
|
219
|
+
premise:** the client-override tier is **not** table-less — `Client/2026-08-14a -
|
|
220
|
+
RecordFieldSettingsClientTable.sql` exists on `origin/toga25-desk` (unmerged, applied nowhere), so
|
|
221
|
+
deleting the tier is the wrong fix. Verified branch scope against live refs: the whole feature is
|
|
222
|
+
only on `origin/toga25-desk` and `origin/_qa-alpha`; `_sandbox-client` and `_production` are both
|
|
223
|
+
`91ce7462` with zero hits, so **production is unaffected**. Also recorded the blast-radius rule
|
|
224
|
+
(one throwing FIELD element fails an entire `metaGroup` response, surfacing as a "network error")
|
|
225
|
+
and the 1054-vs-`__get` distinction that identifies which layer is broken. Captured the branch
|
|
226
|
+
timeline — clean `2de6815b` → re-polluted by merge `a57f13fd` → `fefeebd7` (deployed, 500ing) →
|
|
227
|
+
**rolled back to `_production` at `91ce7462`** on 2026-09-01, which ended the incident and also
|
|
228
|
+
removed the good half, leaving this feature on `toga25-desk` / `_qa-alpha` only (mechanism:
|
|
229
|
+
[recreated shared branch / stale-local
|
|
230
|
+
re-merge](../workflows/recreated-shared-branch-stale-local-remerge.md)). Added the **Forward path**
|
|
231
|
+
— keep `_Model_Client_RecordFieldSetting`, land the two stranded migrations with the code, treat
|
|
232
|
+
`Client/` as a ~30-database fan-out, and write the Core migration **idempotently** because
|
|
233
|
+
sandbox-client's Core already has the hand-applied columns. (apeterson)
|
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-09-01
|
|
10
10
|
owners: [jcardinal, apeterson, bala]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Client/Language.php
|
|
@@ -63,6 +63,7 @@ related:
|
|
|
63
63
|
- ../../api2/features/surface-meta-option.md
|
|
64
64
|
- acl-permission-chain.md
|
|
65
65
|
- sales-order-status-filter-surface.md
|
|
66
|
+
- surface-field-recordfield-binding.md
|
|
66
67
|
- ../../toga25-supply/features/side-navigation-and-default-route.md
|
|
67
68
|
---
|
|
68
69
|
|
|
@@ -154,6 +155,16 @@ boolean rows; the item-record modal (the first boolean consumer) exposed the gap
|
|
|
154
155
|
`_underscore`, so it takes effect only once the framework branch is deployed (framework is pulled at
|
|
155
156
|
deploy).
|
|
156
157
|
|
|
158
|
+
## FIELD elements bound to a RecordField — see the dedicated doc
|
|
159
|
+
|
|
160
|
+
A `renderType = FIELD` element with a non-null `recordFieldId` gets `Core.RecordFields` metadata and
|
|
161
|
+
the field-level ACL write gate folded onto it (`maxLength`, `validationType`, `isWritable`,
|
|
162
|
+
`showRequiredIndicator`, `inputType`, `isSearchable`) inside `_buildBundle`. That capability, its
|
|
163
|
+
drop semantics, its stranded migrations, and the 500 it can cause are documented separately:
|
|
164
|
+
[surface FIELD ↔ RecordField binding](surface-field-recordfield-binding.md). **It is not on
|
|
165
|
+
`_production`, `_beta`, `_sandbox-client` or `_sandbox-dev`** — only `origin/toga25-desk` and
|
|
166
|
+
`origin/_qa-alpha` (verified 2026-09-01).
|
|
167
|
+
|
|
157
168
|
## Vocabulary / label per-client relabeling (slugs are internal, labels are messages)
|
|
158
169
|
|
|
159
170
|
Clients **never rename a Vocabulary**. `Vocabularies.slug` (`sales-order-status`) and
|
|
@@ -247,6 +258,25 @@ cluster-isolation rule.
|
|
|
247
258
|
exact ids to a *different* element. See the NYCHH ship-together constraint in
|
|
248
259
|
[surface-layer-schema](../../dbchanges2/features/surface-layer-schema.md).
|
|
249
260
|
|
|
261
|
+
### ✅ A wrong `surfaceElementId` in an override is INERT — overrides cannot leak across surfaces
|
|
262
|
+
|
|
263
|
+
`_loadOverrides()` (`Model/Core/Surface.php:748`) filters `WHERE SurfaceOverrides.surfaceId =
|
|
264
|
+
$surfaceId`, so an override row is only ever read while rendering **its own** surface. That bounds the
|
|
265
|
+
landmine above: a row whose `surfaceElementId` points at an element belonging to a **different**
|
|
266
|
+
surface is never applied — dead weight, not damage.
|
|
267
|
+
|
|
268
|
+
Proven 2026-08-31. After sandbox-client was reset from production, NYCHH's navigation override still
|
|
269
|
+
carried `surfaceElementId` **170**, which by then belonged to another team's
|
|
270
|
+
`sales-order-denial-details` surface (**47**). It could not affect that surface, because the override
|
|
271
|
+
row's own `surfaceId` is **39** (`navigation`). Confirmed no damage: every denial-details row still
|
|
272
|
+
had `dtCreated = dtUpdated = the reset timestamp`.
|
|
273
|
+
|
|
274
|
+
**So the failure mode of a stale Core id is a MISSING UI element, not a corrupted one** — which is why
|
|
275
|
+
a fairly serious id collision showed up as one absent menu item rather than a broken feature. Two ways
|
|
276
|
+
it can still hurt: (1) the override silently does nothing, so the element you meant to show stays
|
|
277
|
+
hidden; (2) if that id is later reused by an element **inside the same surface**, the override lands on
|
|
278
|
+
the wrong element. Fix the literal — but do not go hunting for collateral damage in other surfaces.
|
|
279
|
+
|
|
250
280
|
### 🚨 …but the REPO still does not describe prod. Read production. (2026-08-28)
|
|
251
281
|
|
|
252
282
|
The reserved-literal house style fixes **new** files. It did **not** retroactively fix the ~51
|
|
@@ -725,6 +755,21 @@ full stop. See
|
|
|
725
755
|
match Compass, a follow-up migration aligning both `meta` and `meta-group` to roles 1,3,4 is needed.
|
|
726
756
|
|
|
727
757
|
## Change history
|
|
758
|
+
- 2026-09-01 — Split the new **FIELD ↔ RecordField binding** capability out into its own doc
|
|
759
|
+
([surface-field-recordfield-binding](surface-field-recordfield-binding.md)) rather than growing this
|
|
760
|
+
one: for a `renderType=FIELD` element with a `recordFieldId`, `_buildBundle` now folds on
|
|
761
|
+
`maxLength` / `validationType` / `isWritable` / `showRequiredIndicator` / `inputType` /
|
|
762
|
+
`isSearchable`. Flagged its branch scope here because it is the kind of thing people grep for and
|
|
763
|
+
wrongly declare missing: it exists **only** on `origin/toga25-desk` and `origin/_qa-alpha`, not on
|
|
764
|
+
`_production` / `_beta` / `_sandbox-client` / `_sandbox-dev`. No code changed this session.
|
|
765
|
+
(apeterson)
|
|
766
|
+
- 2026-08-31 — Recorded a **safety property**: `SurfaceOverrides` cannot leak across surfaces.
|
|
767
|
+
`_loadOverrides()` (`Surface.php:748`) filters on `SurfaceOverrides.surfaceId`, so an override whose
|
|
768
|
+
`surfaceElementId` belongs to a **different** surface is inert. Proven after a Core id collision:
|
|
769
|
+
NYCHH's nav override still pointed at element 170 — by then owned by another team's surface 47 — and
|
|
770
|
+
could not touch it, because the row's own `surfaceId` is 39; every denial-details row still had
|
|
771
|
+
`dtCreated = dtUpdated = the reset timestamp`. The failure mode of a stale Core id is a **missing**
|
|
772
|
+
element, not a corrupted one. (apeterson)
|
|
728
773
|
- 2026-08-28 — Recorded that the reserved-literal house style fixed **new** authoring only: the ~51
|
|
729
774
|
existing `Client_Compass`/`Client_CompassCanada`/`Client_Quad` override files still carry
|
|
730
775
|
cross-cluster `Core.*` joins and are **unrunnable in prod** (verified empirically — the Core and
|
package/knowledge/2.0/apps/_underscore/workflows/recreated-shared-branch-stale-local-remerge.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Deleting a shared branch does not remove bad commits — a stale local clone merges them back"
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: _underscore
|
|
5
|
+
project: _Underscore
|
|
6
|
+
client: shared
|
|
7
|
+
type: workflow
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-09-01
|
|
10
|
+
owners: [apeterson]
|
|
11
|
+
files:
|
|
12
|
+
- _underscore/Model/Core/Surface.php
|
|
13
|
+
- _underscore/Model/Client/RecordFieldSetting.php
|
|
14
|
+
- api2/.platform/hooks/prebuild/git.sh
|
|
15
|
+
- api2/.ebextensions/git.php
|
|
16
|
+
related:
|
|
17
|
+
- ../features/surface-field-recordfield-binding.md
|
|
18
|
+
- ../features/surface-resolver.md
|
|
19
|
+
- ../../api2/features/environment-variable-drives-underscore-branch.md
|
|
20
|
+
- ../../dbchanges2/workflows/framework-branch-ahead-of-schema.md
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Summary
|
|
24
|
+
|
|
25
|
+
**Deleting and recreating a shared environment branch removes only the *ref*.** Every teammate who
|
|
26
|
+
still has that branch checked out locally keeps the full pre-deletion commit history, and the first
|
|
27
|
+
one to push **re-merges it**. Branch surgery is not a durable remedy for a bad commit — **fix forward
|
|
28
|
+
in code.**
|
|
29
|
+
|
|
30
|
+
The recreated branch *looks* fixed, and it genuinely is fixed, for as long as it takes one teammate
|
|
31
|
+
to run `git push`. That gap is usually hours, and the re-merge lands as an innocuous-looking
|
|
32
|
+
"Merge branch 'X' of github.com/… into X" commit that nobody reads.
|
|
33
|
+
|
|
34
|
+
Use this doc when the question is **"we already fixed this — why is it back?"**
|
|
35
|
+
|
|
36
|
+
## Proven timeline (`_underscore` / `_sandbox-client`, 2026-08-31 → 2026-09-01)
|
|
37
|
+
|
|
38
|
+
The subject was the surface FIELD-binding 500 (see
|
|
39
|
+
[surface FIELD ↔ RecordField binding](../features/surface-field-recordfield-binding.md)); the
|
|
40
|
+
mechanism below is what matters and is subject-independent.
|
|
41
|
+
|
|
42
|
+
| When | Ref | What happened |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| 2026-08-31 09:17 | `2de6815b` | Branch **deleted + recreated clean**. Verified: `git merge-base --is-ancestor` said **none** of `a5e79448` / `66022382` / `64466ca2` were ancestors. The deploy off this ref was fine. |
|
|
45
|
+
| 2026-08-31 10:14 | `6f63a62` | `api2/_sandbox-client` likewise recreated. |
|
|
46
|
+
| 2026-08-31 18:20:35 | `a57f13fd` | A teammate pushed. The merge's **two parents** are `64466ca2` (his stale local tip — the pre-deletion history, containing the bug) and `2de6815b` (the clean ref). **This restored the deleted commits.** |
|
|
47
|
+
| 2026-08-31 18:20:42 | `fefeebd7` | `_production` merged on top. Branch tip = `fefeebd7`. |
|
|
48
|
+
| 2026-09-01 | — | The sandbox-client deploy pulled `fefeebd7` (the framework is cloned **at build time** from the moving `_<ENVIRONMENT>` branch) and the 500s returned. |
|
|
49
|
+
| 2026-09-01 (observed mid-session) | `91ce7462` | Branch **rolled back again**, to be byte-identical with `origin/_production`. The live incident ended here. |
|
|
50
|
+
|
|
51
|
+
`api2/_sandbox-client` was **never** re-polluted — it stayed at `6f63a62`. Only the repo whose
|
|
52
|
+
branch a teammate held locally came back.
|
|
53
|
+
|
|
54
|
+
## State as of 2026-09-01 — the vector is still live
|
|
55
|
+
|
|
56
|
+
- **No remote branch carries the bad swap.** `66022382`, `64466ca2`, `a57f13fd` and `fefeebd7` are
|
|
57
|
+
reachable from **zero** remote branches (`git branch -r --contains` → empty).
|
|
58
|
+
- **They survive in local clones.** Which is exactly the condition that produced `a57f13fd` in the
|
|
59
|
+
first place. **Rule 2 below still applies to anyone who has not re-cloned.**
|
|
60
|
+
- **The rollback also reverted the *good* half.** `_sandbox-client` no longer has the FIELD-binding
|
|
61
|
+
feature at all — it now matches `_production`. A rollback is indiscriminate: it does not
|
|
62
|
+
distinguish the regression from the work it arrived with.
|
|
63
|
+
|
|
64
|
+
## Why branch deletion could never have worked here
|
|
65
|
+
|
|
66
|
+
`a5e79448` — the commit that introduces the whole affected code path — is **also** reachable from
|
|
67
|
+
`origin/toga25-desk` and `origin/_qa-alpha`, and `toga25-desk` is merged into `_sandbox-client`
|
|
68
|
+
routinely (e.g. `8bca0286`, 2026-08-27).
|
|
69
|
+
|
|
70
|
+
So even with every stale local clone deleted, the code path returns on the next `toga25-desk` merge.
|
|
71
|
+
**Deleting a ref cannot remove a commit that another branch also reaches.** That is the structural
|
|
72
|
+
reason the decision was to fix forward in code, not to keep doing branch surgery.
|
|
73
|
+
|
|
74
|
+
## Steps — triaging "we already fixed this, why is it back?"
|
|
75
|
+
|
|
76
|
+
1. **Read the merge parents of the reconcile merge.** This names the vector precisely and takes one
|
|
77
|
+
command:
|
|
78
|
+
```
|
|
79
|
+
git log -1 --format='%p' <merge-sha>
|
|
80
|
+
git merge-base --is-ancestor <bad-commit> <each-parent>
|
|
81
|
+
```
|
|
82
|
+
A parent that answers YES is the stale local clone that re-introduced it.
|
|
83
|
+
2. **Ask which branches still reach the bad commit** — not just the one you are looking at:
|
|
84
|
+
```
|
|
85
|
+
git branch -r --contains <bad-commit>
|
|
86
|
+
```
|
|
87
|
+
Empty output means "no remote branch", **not** "gone" — local clones are invisible here.
|
|
88
|
+
3. **Check whether the code path has a second home.** If the introducing commit is reachable from a
|
|
89
|
+
long-lived feature branch that gets merged in routinely (`toga25-desk`, `_qa-alpha`), branch
|
|
90
|
+
surgery is a no-op by design. Fix forward.
|
|
91
|
+
4. **Confirm what the environment actually deployed.** The framework is cloned at build time from a
|
|
92
|
+
moving branch, so the tip at deploy time — not the tip now — is what ran. See
|
|
93
|
+
[ENVIRONMENT decides the `_underscore` branch](../../api2/features/environment-variable-drives-underscore-branch.md),
|
|
94
|
+
including the stack-trace-line-number technique for pinning the running version.
|
|
95
|
+
5. **Pair any branch move with a migration sweep.** Moving a branch in either direction is a schema
|
|
96
|
+
event. See
|
|
97
|
+
[framework branch ahead of schema](../../dbchanges2/workflows/framework-branch-ahead-of-schema.md).
|
|
98
|
+
|
|
99
|
+
## Rules
|
|
100
|
+
|
|
101
|
+
1. **A branch-delete "fix" only appears to work** because the recreated ref happens to predate the
|
|
102
|
+
bad commits. It removes no commit and changes no teammate's clone.
|
|
103
|
+
2. **Before pushing to a re-created shared branch, delete your local copy and re-checkout from
|
|
104
|
+
origin.** Otherwise your first push re-merges everything the recreation was meant to remove.
|
|
105
|
+
Announce a recreation to the team for this reason — it is the only mitigation that works.
|
|
106
|
+
3. **When triaging a returning bug, read the merge parents first.** Do not re-read the code; the
|
|
107
|
+
history says who brought it back.
|
|
108
|
+
4. **Fix forward in code.** Prefer a corrective commit over ref surgery, always.
|
|
109
|
+
5. **Never force-push to recover.** Force-pushing `_main` is forbidden in every repo, and
|
|
110
|
+
force-pushing a shared environment branch multiplies the stale-local problem instead of solving it.
|
|
111
|
+
|
|
112
|
+
## Gotchas / known issues
|
|
113
|
+
|
|
114
|
+
- **The re-merge is invisible in a normal log skim.** It arrives as
|
|
115
|
+
`Merge branch '_sandbox-client' of https://github.com/… into _sandbox-client` — a
|
|
116
|
+
self-referential merge title that reads like routine noise. `--format='%p'` is what exposes it.
|
|
117
|
+
- **A rollback is not a fix either.** It reverts the regression *and* the legitimate work that
|
|
118
|
+
shipped alongside it, and it leaves the underlying commits alive in local clones and on sibling
|
|
119
|
+
branches. Both rollbacks in the timeline above bought hours, not a resolution.
|
|
120
|
+
- **"Verified clean" has a shelf life.** `merge-base --is-ancestor` returning NO is a statement about
|
|
121
|
+
one ref at one moment, not a property of the branch. Re-verify immediately before a deploy.
|
|
122
|
+
- **Two repos, two outcomes, one cause.** `api2` stayed clean and `_underscore` did not, purely
|
|
123
|
+
because of who had which branch checked out locally. Do not infer that one repo is "safer".
|
|
124
|
+
|
|
125
|
+
## Change history
|
|
126
|
+
|
|
127
|
+
- 2026-09-01 — Created from the `_underscore/_sandbox-client` surface FIELD-binding incident (no code
|
|
128
|
+
changed; diagnosis only). Recorded the mechanism — deleting a shared branch removes only the ref,
|
|
129
|
+
and a teammate's stale local clone re-merges the full pre-deletion history — with the exact proven
|
|
130
|
+
timeline: recreated clean at `2de6815b` (Aug 31 09:17), re-polluted by merge `a57f13fd` (Aug 31
|
|
131
|
+
18:20, parents `64466ca2` + `2de6815b`), then `fefeebd7`, then **rolled back to `_production` at
|
|
132
|
+
`91ce7462`** on 2026-09-01, which ended the live incident and simultaneously reverted the good half
|
|
133
|
+
of the work. Noted that `api2/_sandbox-client` was never re-polluted (`6f63a62`) because nobody
|
|
134
|
+
held it locally. Recorded the structural reason branch surgery can never work for this class of
|
|
135
|
+
problem: the introducing commit `a5e79448` is also reachable from `origin/toga25-desk` and
|
|
136
|
+
`origin/_qa-alpha`, which are merged in routinely — so **fix forward in code** was the decision.
|
|
137
|
+
Captured the triage procedure (merge parents via `git log -1 --format='%p'` +
|
|
138
|
+
`merge-base --is-ancestor` per parent; `git branch -r --contains`, whose empty output means "no
|
|
139
|
+
*remote* branch", not "gone") and the four standing rules, including "delete your local copy and
|
|
140
|
+
re-checkout from origin before pushing to a recreated branch" — still live, because the bad commits
|
|
141
|
+
remain in local clones. (apeterson)
|