toga-ai 1.0.717 → 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.
@@ -6,7 +6,7 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-29
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-08-06
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-08-28
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
@@ -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)