toga-ai 1.0.321 → 1.0.323

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
+ | [Proposed — git-sourced base+overlay JSON authoring for the Surface layer](architecture/surface-authoring-proposal.md) | A **proposal / handoff recommendation** (not implemented) that the Surface layer's *authoring* model move off hand-authored SQL against the `SurfaceOverrides` E | _underscore/Model/Core/Surface.php, _underscore/Model/Client/SurfaceOverride.php |
5
6
  | [_underscore Framework Architecture](architecture.md) | `_underscore` is the shared PHP backend framework for **all 2.0 applications**. | _underscore/_underscore.php, _underscore/Loader.php, _underscore/Framework.php, _underscore/Model.php, _underscore/Database.php, _underscore/Query.php, _underscore/Route.php, _underscore/Component.php |
6
7
  | [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, _underscore/Model/Core/Page.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql |
7
8
  | [Address Validation (carrier waterfall + validateAddress scripted endpoint)](features/address-validation.md) | `_Model_Client_Address::validateAddress` verifies a US address against a **carrier waterfall (USPS → FedEx → UPS)** and returns a single canonical, carrier-norm | _underscore/Model/Client/Address.php |
@@ -0,0 +1,67 @@
1
+ ---
2
+ title: Proposed — git-sourced base+overlay JSON authoring for the Surface layer
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: architecture
8
+ status: draft
9
+ updated: 2026-07-13
10
+ owners: [apeterson]
11
+ files:
12
+ - _underscore/Model/Core/Surface.php
13
+ - _underscore/Model/Client/SurfaceOverride.php
14
+ related:
15
+ - ../features/surface-resolver.md
16
+ ---
17
+
18
+ ## Summary
19
+
20
+ A **proposal / handoff recommendation** (not implemented) that the Surface layer's *authoring* model
21
+ move off hand-authored SQL against the `SurfaceOverrides` EAV table onto **git-sourced JSON**: a full
22
+ neutral `_base/<surface>.json` per surface plus partial per-client overlay files holding only diffs,
23
+ merged by the resolver at read time. The runtime resolver is kept for the dynamic parts (per-record
24
+ Tier-2 evaluation + ACL element-drop). This doc records the motivation, the design, and interim
25
+ guardrails so the lead dev can decide.
26
+
27
+ **Critical rules:** This is a **proposal, status: draft — nothing here is implemented.** Do not treat
28
+ any of it as the current system. The live authoring path is still hand-written SQL against
29
+ `SurfaceOverrides`; if you stay on it, apply the interim guardrails below (unique index, strict SQL
30
+ mode, autocommit on, idempotent migrations) or you will hit the silent EAV failure modes.
31
+
32
+ ## Problem
33
+
34
+ Surface config is authored as hand-written SQL against the `SurfaceOverrides` EAV table. This has:
35
+ - **No git diff / review / rollback** — config changes bypass code review and CI.
36
+ - **No team-merge story** — concurrent work on a shared surface collides with no merge tooling.
37
+ - **Silent DB failure modes** — ENUM coercion of unknown values to `''` (non-strict mode), a missing
38
+ unique constraint letting duplicate override rows pile up, and autocommit-off swallowing writes.
39
+
40
+ Cost signal: 2 of ~17 surfaces took ~5 days.
41
+
42
+ ## Recommendation
43
+
44
+ **Source of truth = git.** For each surface author a full neutral `_base/<surface>.json`, plus partial
45
+ per-client overlay files holding **only diffs**, merged by the resolver at read time. Keep the
46
+ resolver for the dynamic parts it must still compute (per-record Tier-2 evaluation + ACL
47
+ element-drop — see [surface-resolver](../features/surface-resolver.md)).
48
+
49
+ **Framing: "source of truth = git; read substrate = a choice."** Three read substrates, in order of
50
+ preference:
51
+ 1. Read the JSON files directly at read time.
52
+ 2. Seed the JSON documents into a DB column at deploy (git still authors; DB is a materialized cache).
53
+ 3. (Avoid) DB-as-master authored by hand — the status quo.
54
+
55
+ ## Optional future — bounded live toggles
56
+
57
+ An admin UI (a screen + a validating API endpoint + an `admin_flags` DB table) that writes a
58
+ **bounded, whitelisted** set of per-client feature toggles, merged at **highest precedence**. This
59
+ gives live no-deploy toggles without undermining git-as-truth, because the whitelist bounds what can
60
+ be changed outside git.
61
+
62
+ ## Interim guardrails (if staying on SQL/EAV)
63
+
64
+ - Unique index on `SurfaceOverrides(surfaceElementId, attribute, roleId, personaId, languageId)`.
65
+ - Strict SQL mode (so unknown ENUM values error instead of coercing to `''`).
66
+ - Autocommit on.
67
+ - Idempotent, re-runnable migrations.
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-01
9
+ updated: 2026-07-13
10
10
  owners: [jcardinal, apeterson]
11
11
  files:
12
12
  - _underscore/Model/Core/Surface.php
@@ -137,6 +137,70 @@ breaks logic.
137
137
  `_Model_<Client>_*` overrides + `Client_ApiPayloadInterceptor` hooks — the Surface layer does
138
138
  **not** absorb business logic.
139
139
 
140
+ ## Per-client override of the Tier-1 gating RULES (not just static flags)
141
+
142
+ Originally only static flags (`isVisible`/`isEnabled`) and `config` were per-client overridable
143
+ via `SurfaceOverrides`; the **Tier-1 gating rules** (`visibilityRule`/`enabledRule`) were frozen on
144
+ the Core element. That meant two-stage-approval clients could not be expressed as data — they needed
145
+ per-client PHP. Two new override attributes make the declarative rules per-client overridable so the
146
+ two-stage behavior is **pure data**:
147
+
148
+ - `_Model_Client_SurfaceOverride` now declares `ATTRIBUTE__VISIBILITY_RULE` and
149
+ `ATTRIBUTE__ENABLED_RULE` constants.
150
+ - In `Surface.php` `_resolveElement`, the resolver decodes and applies both attributes: the locals
151
+ are seeded from the Core element's own `visibilityRule`/`enabledRule`, then overridden by any client
152
+ override row. `_castOverride` decodes them as JSON alongside `CONFIG`.
153
+
154
+ > **⚠ LOCAL only (as of 2026-07-13).** The `_underscore` code change **and** the ENUM migration below
155
+ > must ship together to beta/prod on promotion. php-reviewer pass + a PHPUnit regression test for the
156
+ > rule-override path are still outstanding.
157
+
158
+ **Mandatory ENUM migration (ships with the code).** Each client schema's
159
+ `SurfaceOverrides.attribute` ENUM must be widened *before* any rule-override row is inserted:
160
+ `ALTER TABLE Client_*.SurfaceOverrides MODIFY attribute ENUM(...,'VISIBILITY_RULE','ENABLED_RULE') NOT NULL`
161
+ across all 32 `Client_*` schemas. In **non-strict SQL mode** an insert of an unknown ENUM value
162
+ **silently coerces to `''`** — which makes the resolver ignore the rows and `NOT EXISTS` guards never
163
+ match (duplicate rows pile up). Widen the ENUM first, always.
164
+
165
+ ### Override semantics: REPLACE, not deep-merge
166
+ A `CONFIG` (and now `VISIBILITY_RULE`/`ENABLED_RULE`) override **replaces the whole decoded object**
167
+ — it does **not** deep-merge (`Surface.php` ~line 818). So a `CONFIG` override value must carry the
168
+ **full** config (e.g. `role` + `filterKey` + `additionalData`), not just the changed key.
169
+
170
+ ### Two separate gating mechanisms on the sales-order listing (do not conflate)
171
+ - The **Approvals FILTER** on the sales-order listing is driven by `config.additionalData._status`.
172
+ - The **per-button record actions** are gated by `visibilityRule`/`enabledRule`.
173
+ These are separate mechanisms on separate surfaces; a filter change is a `CONFIG` override, a
174
+ button-gating change is a rule override.
175
+
176
+ ## Core re-baselined to a neutral default + per-client diffs (decision, 2026-07-13)
177
+
178
+ Core was effectively "Client Zero = Compass", not a neutral default, so every client inherited
179
+ Compass's shape. Core was re-baselined as a true shared default so clients store **only diffs**:
180
+
181
+ 1. The approvals gate is governed by the **`navigation-approvals` ACL grant**, not per-tenant
182
+ `IS_VISIBLE` overrides.
183
+ 2. Default approvals filter = `pendingApproval` only (single-stage).
184
+ 3. No Tier-2 `config.tier2` caveat baked into Core.
185
+ 4. Edit-in-Commerce only for Compass / CompassCanada.
186
+ 5. Columns button hidden by default.
187
+
188
+ Per-client diffs layered on top:
189
+ - **Quad** — Approve + Deny only; filter narrowed to `pendingApproval`; filter button visible.
190
+ - **Compass USA / Compass Canada** — two-stage approvals: record-action `visibilityRule`/`enabledRule`
191
+ = `in [pendingApproval, pendingInitialApproval]`. **Compass Canada** additionally gets a `CONFIG`
192
+ override on the approvals filter button (surface `sales-order-listing-actions`) to add
193
+ `pendingInitialApproval`.
194
+
195
+ > ⚠ **Regression left open:** the Compass / CompassCanada **non-rule** overrides (Edit-in-Commerce
196
+ > flags, role-8 Manager hides) were **wiped during the rebuilds and not re-added**. The two-stage rule
197
+ > overrides are also LOCAL only (see above).
198
+
199
+ ### Operational gotcha — raw SQL bypasses the cache-bust
200
+ Raw SQL against the surface tables bypasses the model-layer cache-bust, so the on-disk bundle cache
201
+ at `~/toga/cache/surface_meta/*.json` **must be cleared manually** after any raw-SQL surface edit.
202
+ (Cache confusion earlier was a local-setup artifact, not an inherent design issue.)
203
+
140
204
  ## How a surfaces scripted-API call is authorized (route-level gate)
141
205
 
142
206
  Authorizing a surfaces script (`meta`, `meta-group`, `debug`) is a **two-layer** check. Get the
@@ -234,6 +298,17 @@ Core record grants + their logic-group expressions all evaluate `all`/`"1"`. The
234
298
  match Compass, a follow-up migration aligning both `meta` and `meta-group` to roles 1,3,4 is needed.
235
299
 
236
300
  ## Change history
301
+ - 2026-07-13 — Made the Tier-1 gating RULES per-client overridable: added
302
+ `ATTRIBUTE__VISIBILITY_RULE`/`ATTRIBUTE__ENABLED_RULE` to `_Model_Client_SurfaceOverride`;
303
+ `_resolveElement` seeds locals from the Core element's rules then applies overrides;
304
+ `_castOverride` decodes them as JSON — so two-stage approvals are pure data, not per-client PHP.
305
+ Requires widening `Client_*.SurfaceOverrides.attribute` ENUM (32 schemas) first (non-strict mode
306
+ silently coerces unknown ENUM → `''`). Documented override REPLACE-not-merge semantics, the
307
+ filter (`config.additionalData._status`) vs button-rule split, the raw-SQL cache-bust bypass
308
+ (`~/toga/cache/surface_meta/*.json`), and the Core neutral-default re-baseline (ACL-gated
309
+ approvals, single-stage default, per-client diffs: Quad = Approve+Deny, Compass/CC = two-stage).
310
+ ⚠ Code + ENUM migration are LOCAL only; non-rule Compass/CC overrides wiped in rebuild not yet
311
+ re-added; php-reviewer + regression test outstanding. (apeterson)
237
312
  - 2026-07-01 — Clarified vocabulary/label per-client relabeling (new section): Vocabulary `slug`
238
313
  and VocabularyTerm `value` are stable internal ids never shown to users; the displayed label
239
314
  always resolves from `Core.Messages`; clients relabel via a `SurfaceOverrides` `LABEL_MESSAGE`
@@ -8,5 +8,5 @@
8
8
  | [Column Visibility (URL-driven show/hide columns)](features/column-visibility.md) | A "Columns" header button that opens a modal listing every column from the table meta, lets the user show/hide columns, adjusts the table live, and persists the | toga25-supply/src/components/ColumnVisibilityModal/, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx, toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableData.tsx |
9
9
  | [Meta-Driven Page & Table Setup](features/meta-driven-table-data.md) | A page in this app is **meta-driven end to end**: the page view model fetches *page meta* (labels, sections, ACL) and *table meta* (the columns/fields + table s | toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableState.ts, toga25-supply/src/hooks/useTablePageMeta.ts, toga-blox-npm/dist/hooks/useFetchPageMeta.d.ts, toga-blox-npm/dist/hooks/useFetchTablePageMeta.d.ts, toga-blox-npm/dist/hooks/useAssignTableFieldLabels.d.ts, toga-blox-npm/dist/components/Table/hooks/useTableData.d.ts |
10
10
  | [Record Modals & Nested Tables](features/record-modals-and-nested-tables.md) | The repo's family of modal + nested-table patterns layered over toga-blox `TableRecordModal` and `PrimaryTable*Layout`. | toga25-supply/src/layout/ItemRecordModalLayout/, toga25-supply/src/layout/SalesOrderRecordModalLayout/, toga25-supply/src/layout/SalesOrderItemsTableLayout/, toga25-supply/src/layout/ItemFulfillmentModal/, toga25-supply/src/layout/GenericNestedTables/, toga25-supply/src/hooks/useTableCellInteractions.ts |
11
- | [Surface Frontend (DB-driven UI consumption, src/surface/)](features/surface-frontend.md) | The frontend consumer of the platform-wide Surface layer — DB-driven UI config fetched from `GET /v2/surfaces/meta?slug=<slug>` instead of statically-imported J | toga25-supply/src/surface/useFetchSurfaceMeta.ts, toga25-supply/src/pages/SalesOrders/helpers/surfaceBundleToTenantFields.ts, toga25-supply/src/pages/SalesOrders/helpers/buildPatchedTenantFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/SalesOrderView.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/SalesOrderSummaryGrid.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getDetailSections.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/index.ts, toga25-supply/src/surface/evaluateSurfaceRule.ts, toga25-supply/src/surface/actionRegistry.ts, toga25-supply/src/surface/componentRegistry.tsx, toga25-supply/src/surface/SurfaceActionBar.tsx, toga25-supply/src/surface/SurfaceSection.tsx, toga25-supply/src/surface/resolve.ts, toga25-supply/src/surface/types.ts, toga25-supply/src/surface/index.ts, toga25-supply/src/pages/Login/LoginPage.tsx, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderTopBar.tsx, toga25-supply/src/pages/Items/ItemsPage.tsx, toga25-supply/src/pages/Items/viewModel/useItemsPageViewModel.tsx, toga25-supply/src/pages/VendorItems/VendorItemsPage.tsx, toga25-supply/src/pages/VendorItems/viewModel/useVendorItemsPageViewModel.tsx, toga25-supply/src/pages/Inventory/Inventory.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/pages/Inventory/viewModel/FIELDS/index.ts, toga25-supply/src/fieldsConfig/index.ts |
11
+ | [Surface Frontend (DB-driven UI consumption, src/surface/)](features/surface-frontend.md) | The frontend consumer of the platform-wide Surface layer — DB-driven UI config fetched from `GET /v2/surfaces/meta?slug=<slug>` instead of statically-imported J | toga25-supply/src/surface/useFetchSurfaceMeta.ts, toga25-supply/src/pages/SalesOrders/helpers/surfaceBundleToTenantFields.ts, toga25-supply/src/pages/SalesOrders/helpers/buildPatchedTenantFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/SalesOrderView.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/SalesOrderSummaryGrid.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getDetailSections.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/index.ts, toga25-supply/src/surface/evaluateSurfaceRule.ts, toga25-supply/src/surface/actionRegistry.ts, toga25-supply/src/surface/componentRegistry.tsx, toga25-supply/src/surface/SurfaceActionBar.tsx, toga25-supply/src/surface/SurfaceSection.tsx, toga25-supply/src/surface/resolve.ts, toga25-supply/src/surface/types.ts, toga25-supply/src/surface/index.ts, toga25-supply/src/pages/Login/LoginPage.tsx, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx, toga25-supply/src/pages/SalesOrders/view/SurfaceRowActions.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderTopBar.tsx, toga25-supply/src/pages/Items/ItemsPage.tsx, toga25-supply/src/pages/Items/viewModel/useItemsPageViewModel.tsx, toga25-supply/src/pages/VendorItems/VendorItemsPage.tsx, toga25-supply/src/pages/VendorItems/viewModel/useVendorItemsPageViewModel.tsx, toga25-supply/src/pages/Inventory/Inventory.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/pages/Inventory/viewModel/FIELDS/index.ts, toga25-supply/src/fieldsConfig/index.ts |
12
12
  | [Cypress Testing Harness (component + e2e)](workflows/cypress-testing.md) | The Cypress test harness for the `toga25-supply` frontend, bootstrapped from scratch (`cypress` was already a dependency but there was no config, no `cypress/` | toga25-supply/cypress.config.ts, toga25-supply/cypress/tsconfig.json, toga25-supply/cypress/support/component.tsx, toga25-supply/cypress/support/component-index.html, toga25-supply/cypress/support/e2e.ts, toga25-supply/cypress/support/commands.ts, toga25-supply/cypress/support/fixtures.ts, toga25-supply/cypress/support/mocks/useApprovalModalViewModel.ts, toga25-supply/cypress/component/SalesOrderApprovalModalsLayout.cy.tsx, toga25-supply/cypress/component/RecordApprovalModalLayout.cy.tsx, toga25-supply/cypress/component/EnterPoNumberModal.cy.tsx, toga25-supply/cypress/e2e/salesOrderApproval.cy.ts |
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-30
9
+ updated: 2026-07-13
10
10
  owners: [jcardinal, apeterson]
11
11
  files:
12
12
  - toga25-supply/src/surface/useFetchSurfaceMeta.ts
@@ -27,6 +27,7 @@ files:
27
27
  - toga25-supply/src/surface/index.ts
28
28
  - toga25-supply/src/pages/Login/LoginPage.tsx
29
29
  - toga25-supply/src/pages/SalesOrders/SalesOrders.tsx
30
+ - toga25-supply/src/pages/SalesOrders/view/SurfaceRowActions.tsx
30
31
  - toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderTopBar.tsx
31
32
  - toga25-supply/src/pages/Items/ItemsPage.tsx
32
33
  - toga25-supply/src/pages/Items/viewModel/useItemsPageViewModel.tsx
@@ -245,10 +246,24 @@ seeding NYCHH/Prudential/SPGlobal is deferred; the Client-DB prod cross-cluster
245
246
  (the path form 404s EV-6).
246
247
  - **Tier-1 only on the client.** Business-logic gates (Tier-2) arrive as resolved booleans from the
247
248
  backend (`meta.surface`); never re-encode business rules in the FE evaluator.
249
+ - **A React portal overlay's click bubbles through the COMPONENT tree, not the DOM tree.** The
250
+ SalesOrders row-actions menu (`SurfaceRowActions.tsx`) renders its dismiss overlay via a portal;
251
+ an outside-click on that fixed overlay propagated up the React component tree to the row's
252
+ `handleRowClick` and **opened the record modal**. Fix: call `e.stopPropagation()` (plus close) in
253
+ the overlay's `onClick`. Portal DOM position does not stop React synthetic-event bubbling.
254
+ - **The sticky row-actions column must be gated on the active filter.** In `SalesOrders.tsx` the
255
+ `actionColumns` array is only included when `activeFilterKey === APPROVALS_FILTER_KEY`
256
+ (`"approvals"`); otherwise the row-actions column showed unconditionally.
248
257
  - **`tsc` not yet run** this session (the private `@agilant/toga-blox` registry needs npm creds);
249
258
  treat type-checking as pending. Runtime `GET /v2/surfaces/{slug}/meta` also not yet exercised.
250
259
 
251
260
  ## Change history
261
+ - 2026-07-13 — Fixed two SalesOrders row-actions bugs: (1) the portal dismiss overlay in
262
+ `SurfaceRowActions.tsx` propagated an outside-click up the React component tree to the row handler
263
+ and opened the record modal — added `e.stopPropagation()` (+ close) on the overlay `onClick`;
264
+ (2) gated the sticky `actionColumns` array in `SalesOrders.tsx` on
265
+ `activeFilterKey === APPROVALS_FILTER_KEY` so the row-actions column only shows under the Approvals
266
+ filter. (apeterson)
252
267
  - 2026-06-30 — Follow-up: fixed the GROUPED meta fetch returning `{}` (every migrated-client
253
268
  SalesOrders section had vanished). The meta-group envelope double-nests the slug-keyed `surfaces`
254
269
  map under an outer ROUTE-keyed `surfaces` slot; the old `extractGroup` grabbed the first (outer,
@@ -17,7 +17,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
17
17
 
18
18
  ## 2.0 framework
19
19
 
20
- - **_underscore** (_Underscore) _(framework core)_ — 28 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
20
+ - **_underscore** (_Underscore) _(framework core)_ — 29 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
21
  - **worker2** (Worker) — 27 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
22
22
  - **api2** (API) — 10 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
23
23
  - **dbchanges2** (Database Changes) _(framework core)_ — 3 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -7,6 +7,7 @@
7
7
  | [Compass: Item-Fulfillment TableViews (for-sales-order-items & for-sales-orders, tracking via bridge)](features/item-fulfillment-tracking-tableview.md) | 2.0 | Two sibling Compass TableViews in `Client_Compass` display fulfilled items in toga2-supply, both driven by `TableViews` / `TableViewJoins` / `TableViewFields` c | dbchanges2/Client_Compass/2026-06-10 - ItemFulfillmentsForSalesOrderItemsTableView.sql, dbchanges2/Client_Compass/2026-06-11 - ItemFulfillmentsForSalesOrdersTableView.sql, dbchanges2/Client_Compass/2026-06-15a - FixItemFulfillmentTrackingNumberJoins.sql |
8
8
  | [Compass MITS PO → SO Item Linking](features/mits-po-to-so-item-linking.md) | 2.0 | MITS sends Compass inbound Purchase Orders (`POST /v2/purchase-orders`) against a Sales Order (`mitsSalesOrder`). | _underscore/Model/Compass/PurchaseOrder.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php |
9
9
  | [Compass MITS PO Transmission to Vendors](features/mits-po-transmission-to-vendors.md) | 2.0 | The 1.0 worker cron `2_transmit_mits_purchase_orders_to_vendors.php` transmits Compass PurchaseOrders to their vendors (Office Depot, Strategic Systems, Compass | worker/crons/toga2/compass/workflow/2_transmit_mits_purchase_orders_to_vendors.php, library/app/client/compass.php |
10
+ | [Compass MR/MA Order Auto-Approval & Status Gate](features/mr-ma-order-approval-and-status.md) | 2.0 | Compass **MR** and **MA** sales orders are system-generated from the MITS / Office Depot EDI pipeline (they do not originate as user-entered SA orders) and must | _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/PurchaseOrder.php, worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php |
10
11
  | [Compass USA](profile.md) | 2.0 | Compass USA is a TOGA client running a multi-tier supply-chain commerce operation. | |
11
12
  | [Compass Cross-Kit Bundle Corruption — Detection & Repair](workflows/cross-kit-bundle-corruption.md) | 2.0 | A frontend regression in `toga2-commerce`'s edit-order bundle submission mis-attributed bundle (kit) line items and **fees/warranties** to the **wrong kit**, pe | src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts |
12
13
  | [Compass ODP Order Pipeline to NetSuite (numbered worker crons)](workflows/odp-order-pipeline-to-netsuite.md) | 1.0 | The end-to-end **Compass Office Depot (ODP) order → NetSuite** pipeline as it actually runs through the 1.0 `worker` crons under `worker/crons/toga2/compass/`, | worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php, worker/crons/toga2/compass/workflow/2_transmit_mits_purchase_orders_to_vendors.php, worker/crons/toga2/compass/edi/1_download_edi_s3_create_po_toga.php, worker/crons/toga2/compass/workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php, library/app/client/compass.php |
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-06-16
10
- owners: ["jcardinal"]
9
+ updated: 2026-07-13
10
+ owners: ["jcardinal", "rgirish"]
11
11
  files:
12
12
  - _underscore/Model/Compass/PurchaseOrder.php
13
13
  - worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php
@@ -40,6 +40,15 @@ production data-integrity bug.
40
40
  `createdFromSalesOrderItem`, and deliberately **aligns** SO and PO `lineNumber`s. It does
41
41
  NOT build the item bridge — so the `postPost` lineNumber-match pass is the **only** (and
42
42
  correct) MR linking mechanism.
43
+ - **MA orders — item population in the ODP PO import cron:** the 3a cron
44
+ (`worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php`) parses ODP
45
+ EDI 850 POs. Its "Non-SA order handling" block creates the Compass **MA** sales order. Line
46
+ items are attached to the Compass SO **only in the new-SO branch**, from `$purchaseOrder['items']`
47
+ (`partNumber` + `UUID_ITEM_CATALOG` Compass catalog + `qtyOrdered` + `unitPrice`), mirroring the
48
+ ODP-facing SO item payload built lower in the same cron. The item-linking block that maps ODP
49
+ items back to the Compass side runs only `if (… == 'SA' || … == 'MR')` — MA is excluded there
50
+ because SA/MR originate in Compass (their Compass SO+items pre-exist) whereas MA is
51
+ ODP-originated, so nothing else would populate it.
43
52
 
44
53
  ## Data model
45
54
  - `SalesOrderItems_PurchaseOrderItems` (`Client_Compass`): `uuid`, `salesOrderItemId`,
@@ -115,7 +124,22 @@ USA and Canada share the parent handler unchanged.
115
124
  off-by-one bridge fingerprint (a `SalesOrderItems_PurchaseOrderItems` PO item linked to two
116
125
  adjacent SO items). Only SA132763 was remediated; a broader reviewed backfill is pending.
117
126
 
127
+ - **Empty MA orders (fixed 2026-07-13 — forward only).** The 3a cron's new-SO branch created MA
128
+ Compass sales orders with **no** `salesOrderItems`, because item population lived only on the
129
+ ODP-facing SO and the back-linking block excludes MA (`if (… == 'SA' || … == 'MR')`). Empty MA
130
+ (and MR) shells then stick at "Pending Initial Approval" because Compass `_status` gates on
131
+ approvals first — see
132
+ [MR/MA Order Auto-Approval & Status Gate](mr-ma-order-approval-and-status.md). **Fix:** build a
133
+ `salesOrderItems` array from `$purchaseOrder['items']` in the new-SO branch (partNumber +
134
+ `UUID_ITEM_CATALOG` + qtyOrdered + unitPrice), mirroring the ODP SO item payload. Only the
135
+ new-SO branch changed; the SO-already-exists branch was intentionally left alone. `php -l`
136
+ passes. This stops NEW empty MA orders; it does **not** repair the 192 historical empties (which
137
+ need item backfill from the sibling ODP SO first).
138
+
118
139
  ## Change history
140
+ - 2026-07-13 — Fixed empty MA orders: the 3a ODP-PO-import cron's new-SO branch now builds
141
+ `salesOrderItems` from the 850 PO items (the MA back-linking block excludes MA by design).
142
+ Forward-only; 192 historical empties still need item backfill. (rgirish)
119
143
  - 2026-06-16 — Found and fixed a SECOND source of cross-part bridge links: the Office Depot
120
144
  worker cron `3a_import_office_depot_purchase_orders.php` paired API-returned PO items to SO
121
145
  items by array index; now pairs by lineNumber. Remediated SA132763's spurious bridge +
@@ -0,0 +1,124 @@
1
+ ---
2
+ title: Compass MR/MA Order Auto-Approval & Status Gate
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: compass-usa
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-07-13
10
+ owners: ["rgirish"]
11
+ files:
12
+ - _underscore/Model/Compass/SalesOrder.php
13
+ - _underscore/Model/Compass/PurchaseOrder.php
14
+ - worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php
15
+ related:
16
+ - mits-po-to-so-item-linking.md
17
+ - ../workflows/odp-order-pipeline-to-netsuite.md
18
+ - ../workflows/order-lifecycle-and-data-integrity.md
19
+ - ../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
20
+ ---
21
+
22
+ ## Summary
23
+ Compass **MR** and **MA** sales orders are system-generated from the MITS / Office Depot EDI
24
+ pipeline (they do not originate as user-entered SA orders) and must **skip human approval
25
+ entirely**. Compass's `_status` calculated field gates on **approvals FIRST**: if any stage of
26
+ the active `ApprovalTemplate` has no `ApprovalDecision`, `_status` returns the pending-stage slug
27
+ (`pendingInitialApproval`) *before* it ever evaluates fulfillment. So an MR/MA order that has an
28
+ `Approval` row but no decisions is stuck at "Pending Initial Approval" regardless of shipment.
29
+
30
+ Two independent root causes produced the stuck-order population, and both are addressed at the
31
+ source (auto-approval + item population), **not** in the status SQL.
32
+
33
+ ## Key files / entry points
34
+ - **`_underscore/Model/Compass/SalesOrder.php`**
35
+ - `_status` calculated field — approval gate runs before the shipped-only fulfillment machine
36
+ (see [IF stage lifecycle & order status](../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md)
37
+ for the fulfillment half).
38
+ - `postPost` — the `if ($isMrOrder)` block loops `foreach ([1,2] as $step)` and issues two
39
+ `internalApiRequest('POST', '/approval-decisions', …)` with `isApproved=true`,
40
+ note `'Auto-approved: MR order'`. **Went live 2026-06-03.**
41
+ - **`_underscore/Model/Compass/PurchaseOrder.php`** — MR handling (`handleMrOrder`); MR/MA order
42
+ detection.
43
+ - **`worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php`** — the
44
+ hourly ODP 850 import that creates MA orders (see the item-population fix in
45
+ [MITS PO → SO Item Linking](mits-po-to-so-item-linking.md)).
46
+
47
+ ## How it works
48
+ ### The approval gate (why empty + un-decided orders stick)
49
+ Prod `Client_Compass` has one active `ApprovalTemplate` (id 1, `salesOrders`) with two stages —
50
+ stage 1 = `step1` = `pendingInitialApproval`, stage 2 = `step2` = `pendingApproval`;
51
+ `APPROVAL_DECISION_TYPE = 1`. The base `_Model_Client_SalesOrder::postPost` creates an `Approval`
52
+ row on **any** order whenever an active template exists. Compass `_status` then requires an
53
+ `ApprovalDecision` for every active-template stage before it will look at fulfillment. No
54
+ decision on a stage ⇒ `_status` short-circuits to that stage's slug.
55
+
56
+ ### Auto-approval (the intended MR path)
57
+ `_Model_Compass_SalesOrder::postPost` auto-approves MR orders by POSTing an approval decision for
58
+ each of the two stages (`isApproved=true`). `_status` only checks `isApproved` on the decision —
59
+ never the `assignedToUserId` / `decidedByUserId` columns — so a system decision with NULL user
60
+ columns is fully valid.
61
+
62
+ ## Client variations
63
+ - **MR** orders originate in Compass; their Compass SO + items pre-exist. Auto-approval covers them
64
+ from 2026-06-03 onward.
65
+ - **MA** orders are **Office-Depot-originated** (created by the 3a cron) and were **never**
66
+ auto-approved — the `isMrOrder` check only matches numbers starting `MR`, so MA falls through
67
+ the gate. MA orders were additionally created as **empty shells** (see below).
68
+
69
+ ## Gotchas / known issues
70
+ - **The stuck orders are EMPTY (zero `SalesOrderItems`).** All stuck MR+MA orders (and their linked
71
+ Compass PO) have no line items — the real line items live on a sibling **Office-Depot-facing SO**
72
+ (`customerId = OfficeDepot`) reachable via `SalesOrders_PurchaseOrders → PurchaseOrders_SalesOrders`.
73
+ A fulfilled-short-circuit in `_status` therefore does **nothing** for them (nothing to fulfill).
74
+ - **Do NOT "fix" this in `_status`.** A speculative fulfilled-short-circuit (plus an extracted
75
+ `_isFullyFulfilledSql` helper) was added and then **reverted** this session: because the stuck
76
+ orders are empty, the short-circuit never fires. The real fix is item population + approval
77
+ decisions at the source.
78
+ - **MA is still creating stuck orders until the item-population cron fix ships** — see
79
+ [MITS PO → SO Item Linking](mits-po-to-so-item-linking.md). Auto-approval for MA is not yet
80
+ implemented (the `isMrOrder` check does not match `MA`).
81
+
82
+ ## Prod status buckets (2026-07-13)
83
+ | Type | NO_APPROVAL (correct — pre-template, gate falls through) | APPROVAL_NO_DECISION (STUCK) | FULLY_APPROVED |
84
+ |---|---|---|---|
85
+ | MR | 5922 (Sep 2025–Jan 2026) | 2927 | 583 |
86
+ | MA | 176 | 192 | 0 |
87
+
88
+ - The 5922 MR + 176 MA `NO_APPROVAL` orders pre-date the active template; they have **no**
89
+ `Approval` row, so `_status` skips the gate and reads correctly. They must **NOT** get an
90
+ `Approval` created.
91
+ - Total stuck: **3119** (2927 MR + 192 MA). A CSV of all 3119 (type, orderNumber, orderUuid,
92
+ dateCreated, compassPurchaseOrder, officeDepotSalesOrder_sourceOfItems) was handed to
93
+ ops/integration for remediation.
94
+
95
+ ## Historical MR remediation (approval-decision backfill)
96
+ A one-off, idempotent, transaction-wrapped SQL backfill unsticks the 2927 historical MR orders by
97
+ inserting the two auto-approval `ApprovalDecisions` (one per stage, mirroring `postPost`'s
98
+ `foreach([1,2])`). Each row: `approvalTemplateStageId` 1/2, `approvalDecisionTypeId=1`,
99
+ `isApproved=1`, `assignedToUserId`/`decidedByUserId` NULL, note
100
+ `'Auto-approved: MR order (historical backfill)'`, `dtDecision NOW()`.
101
+
102
+ Safe scoping baked in — **decisions only, never Approvals**:
103
+ - **`INNER JOIN Approvals` (`recordId=14`)** so it can only add decisions to orders that *already*
104
+ have an `Approval`. Structurally it cannot touch the `NO_APPROVAL` orders (which are already
105
+ correct and must not get an Approval).
106
+ - **Per-stage `NOT EXISTS`** ⇒ idempotent / re-runnable.
107
+ - Includes a PREVIEW count query (prod: 2927 orders → 2927 step-1 + 2927 step-2 rows). Workflow:
108
+ run on beta, verify, COMMIT; then production.
109
+ - Does **not** create Approvals; does **not** handle MA (MA orders are empty and need item backfill
110
+ from the sibling ODP SO first).
111
+
112
+ ## Change history
113
+ - 2026-07-13 — Diagnosed the MR/MA "Pending Initial Approval" stuck-order bug: `_status` gates on
114
+ approvals before fulfillment; MR orders created 2026-02-04..2026-06-02 got an Approval but no
115
+ decisions (auto-approval only live from 2026-06-03), and MA orders were never auto-approved and
116
+ are empty shells. Reverted a speculative `_status` fulfilled-short-circuit (stuck orders are
117
+ empty, so it never fires). Built an idempotent, decisions-only MR approval-decision backfill
118
+ (2927 orders) scoped by `INNER JOIN Approvals` so it can never create an Approval, and a CSV of
119
+ all 3119 stuck orders for ops. MA item population fixed separately at the 3a cron. (rgirish)
120
+
121
+ ## Related docs
122
+ - [Compass MITS PO → SO Item Linking](mits-po-to-so-item-linking.md) — the 3a cron item-population fix that stops NEW empty MA orders.
123
+ - [Compass ODP Order Pipeline to NetSuite](../workflows/odp-order-pipeline-to-netsuite.md)
124
+ - [IF Stage Lifecycle & Order Status](../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md) — the shipped-only fulfillment half of Compass `_status`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.321",
3
+ "version": "1.0.323",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",