toga-ai 1.0.835 → 1.0.837

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.
@@ -17,6 +17,6 @@
17
17
  | [Tools Persona-Gated Navigation (App_Nav)](features/persona-gated-navigation.md) | `App_Nav` is the Tools app's two-level, **persona-gated** navigation. |
18
18
  | [Tools SAML SSO Consumer & Persona-Gated Auth (App_Auth)](features/saml-sso-auth.md) | `App_Auth` is the Tools app's authentication layer: it consumes the SAML gateway `?saml=` handoff (see the 2.0 SAML downstream integration contract), establishe |
19
19
  | [Talos Knowledge Base Admin UI (KB Documents + Vocabulary)](features/talos-kb-documents-admin.md) | > **PER-AI-MODEL, DATA-DRIVEN SCOPING (2026-07-29).** The KB-documents and Vocabulary admin > UIs were refactored from a single hard-coded **"development-team"* |
20
- | [Talos Pricing UI (Contracts, Pricing Dashboard, Usage, Settings + Estimator)](features/talos-pricing-ui.md) | The 1.0 (tools app) face of the **Talos Pricing Platform** — a "Talos Pricing" nav folder with four pages plus the estimate engine. |
20
+ | [Talos Pricing UI (Contracts, Pricing Dashboard, Usage, Settings + Estimator)](features/talos-pricing-ui.md) | The 1.0 (tools) face of the Talos Pricing Platform — Contracts / Pricing Dashboard / Usage / Pricing Settings pages + the estimate engine, reading the Team DB v |
21
21
  | [App-Wide Colour Theme (light / dark / auto)](features/theme-light-dark.md) | A **light / dark / auto** colour theme for the *entire* Tools app, built as a single semantic-token layer (`assets/css/theme.css`) rather than per-page edits. |
22
22
  | [Deploying Tools to Elastic Beanstalk (PHP 8.5 / Amazon Linux 2023)](workflows/deploy-to-elastic-beanstalk-al2023.md) | How the **Tools** 1.0 app boots on Elastic Beanstalk running `PHP 8.5 on 64bit Amazon Linux 2023/4.13.1 (aarch64)`. |
@@ -6,11 +6,12 @@ project: Tools
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-04
9
+ updated: 2026-09-17
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - tools/_/app/nav.php
13
13
  - tools/_/app/talos/estimator.php
14
+ - tools/_/app/talos/estimator.parity.test.php
14
15
  - tools/_/app/talos/usage.php
15
16
  - tools/mvc/talos/contracts/get.php
16
17
  - tools/mvc/talos/contracts/post.php
@@ -29,100 +30,56 @@ related:
29
30
  - ../../../2.0/apps/worker2/features/talos-pricing-automation.md
30
31
  ---
31
32
 
33
+ The 1.0 (tools) face of the Talos Pricing Platform — Contracts / Pricing Dashboard / Usage / Pricing Settings pages + the estimate engine, reading the Team DB via `db_team`; open for the contract lifecycle, worker-owned read-only rows, or the fetchOne/PHP 8.5 traps.
34
+
32
35
  ## Summary
33
36
 
34
- The 1.0 (tools app) face of the **Talos Pricing Platform** — a "Talos Pricing" nav folder
35
- with four pages plus the estimate engine. Restructured 2026-08-03 to
36
- **Contracts / Pricing Dashboard / Usage / Pricing Settings**. All pages read the **Team DB**
37
- through the `db_team` connection (`[database_team]` in `config.*.ini`).
37
+ The 1.0 (tools app) face of the **Talos Pricing Platform** — a "Talos Pricing" nav folder with four pages plus the estimate engine. Restructured 2026-08-03 to **Contracts / Pricing Dashboard / Usage / Pricing Settings**. All pages read the **Team DB** through the `db_team` connection (`[database_team]` in `config.*.ini`).
38
38
 
39
39
  ## How it works
40
40
 
41
41
  ### Contracts (`mvc/talos/contracts/{get,post}.php`) — replaces `onboarding/*`
42
- The old Onboarding page could only **INSERT** (hardcoded `status='ONBOARDED'`, no edit, no
43
- delete), which is why nothing ever reached the tables. Contracts is a real lifecycle, reusing
44
- the existing status enum: **PROSPECT** = draft, **ACTIVE** = signed, **CHURNED** = ended.
45
-
46
- - **A signed contract can never be hard-deleted.** `TalosPricingBands` is
47
- `ON DELETE CASCADE`, so deleting the client would destroy the agreed rate schedule and the
48
- margin history with it. Signed contracts **churn**; they do not delete.
49
- - **Post-signature edits append to `TalosContractAmendments`** rather than mutating the
50
- signed terms.
51
- - Includes an **Actual-vs-Estimate** comparison with variance chips, a month-by-month history
52
- table plus cost/margin chart, and a one-click **"apply recommended org fee"**.
42
+ The old Onboarding page could only **INSERT** (hardcoded `status='ONBOARDED'`, no edit, no delete), which is why nothing ever reached the tables. Contracts is a real lifecycle, reusing the existing status enum: **PROSPECT** = draft, **ACTIVE** = signed, **CHURNED** = ended.
43
+ - **A signed contract can never be hard-deleted.** `TalosPricingBands` is `ON DELETE CASCADE`, so deleting the client would destroy the agreed rate schedule and the margin history with it. Signed contracts **churn**; they do not delete.
44
+ - **Post-signature edits append to `TalosContractAmendments`** rather than mutating the signed terms.
45
+ - Includes an **Actual-vs-Estimate** comparison with variance chips, a month-by-month history table plus cost/margin chart, and a one-click **"apply recommended org fee"**.
46
+ - **Pricing model toggle + contract terms (2026-09-17).** The page now sets `pricingModel` (`PER_USER` / `PER_INTERACTION`), the fixed 1/3/5-year term dropdown (`contractTermMonths`), the per-interaction inputs (`contractedInteractionCount`, voice-only `avgMinutesPerInteraction`, `perInteractionFee`), and the two one-time contract fees (`implementationFee`, `trainingFee`). Model design, term ramp, fee amortization, and the recurring-vs-blended margin split are all in [pricing-cogs-model](../../../2.0/apps/talos/features/pricing-cogs-model.md) — not restated here.
53
47
 
54
48
  ### Pricing Dashboard (`pricing/get.php`) — rebuilt for sales leadership
55
- Portfolio KPIs, a 12-month blended-margin trend with the target band shaded, an exception
56
- list with deterministic explanations, and licensed-vs-active utilisation.
49
+ Portfolio KPIs, a 12-month blended-margin trend with the target band shaded, an exception list with deterministic explanations, and licensed-vs-active utilisation.
57
50
 
58
51
  ### Usage (`usage/get.php`, `_/app/talos/usage.php`)
59
- Adds `costIntensity()` and a **Cost intensity** panel (tool-mix driven cost).
60
-
61
- **Fixed:** `clientOptions()` read `TalosClients WHERE status='ONBOARDED'` from an
62
- always-empty table, so the client selector rendered **empty**. It now calls
63
- `GET /api/usage/clients` with a Team-DB fallback.
52
+ Adds `costIntensity()` and a **Cost intensity** panel (tool-mix driven cost). **Fixed:** `clientOptions()` read `TalosClients WHERE status='ONBOARDED'` from an always-empty table, so the client selector rendered **empty**. It now calls `GET /api/usage/clients` with a Team-DB fallback.
64
53
 
65
54
  ### Pricing Settings (`settings/{get,post}.php`) — replaces `factors/*`
66
- Technical-only (persona-narrowed on the action, per `mvc-data-access-patterns`). **Measured
67
- (derived) cost-factor rows are worker-owned and read-only**, enforced server-side with
68
- `UPDATE ... AND isDerived = 0`. Policy rows are editable and carry full-sentence labels.
55
+ Technical-only (persona-narrowed on the action, per `mvc-data-access-patterns`). **Measured (derived) cost-factor rows are worker-owned and read-only**, enforced server-side with `UPDATE ... AND isDerived = 0`. Policy rows are editable and carry full-sentence labels.
69
56
 
70
57
  ### Removed
71
- - `mvc/talos/benchmarks/get.php` — read the never-populated `TalosUsageMonthly` and
72
- duplicated a worse subset of the Usage page.
58
+ - `mvc/talos/benchmarks/get.php` — read the never-populated `TalosUsageMonthly` and duplicated a worse subset of the Usage page.
73
59
  - `mvc/talos/onboarding/*` and `mvc/talos/factors/*` — superseded above.
74
60
 
75
61
  ### Estimator (`App_Talos_Estimator`, `_/app/talos/estimator.php`)
76
- Now driven by the **token unit price** and **workload profiles** instead of the feature
77
- checklist and per-client calibration factor (methodology in `pricing-cogs-model`). Service
78
- offering (CHAT / VOICE_TO_VOICE / NATURAL_VOICE) describes **modality only** and says nothing
79
- about cost; cost is driven by tool-call **intensity** via the profile
80
- (retrieval / general / analytics).
62
+ Now driven by the **token unit price** and **workload profiles** instead of the feature checklist and per-client calibration factor (methodology in `pricing-cogs-model`). Service offering (CHAT / VOICE_TO_VOICE / NATURAL_VOICE) describes **modality only** and says nothing about cost; cost is driven by tool-call **intensity** via the profile (retrieval / general / analytics). Per-interaction model adds `costPerInteraction`, `estimateByInteraction`, `recommendedPricePerInteraction`, `recommendedOrgFeeInteraction`, `orgFeeOptionsInteraction`. Constants live in Team-DB `TalosCostFactors` (shared with worker2); a golden-master parity test (`estimator.parity.test.php` ↔ worker2 `PricingParityTest.php`, 11/11 pass) guards drift — see [pricing-cogs-model](../../../2.0/apps/talos/features/pricing-cogs-model.md).
81
63
 
82
64
  ## Gotchas
83
-
84
- - **`App_Database::fetchOne()` is not an existence test** — it 500s on zero rows. See
85
- [mvc-data-access-patterns](./mvc-data-access-patterns.md); the original
86
- `onboarding/post.php` carried this exact bug and would have fataled on the first save
87
- anyone attempted, which is good evidence nobody ever completed an onboarding through it.
88
- - **This app runs PHP 8.5 and escalates deprecations to fatals** — `$arr[$row['nullableCol']]`
89
- is fatal even inside `isset()`. Local dev on PHP 8.2 does **not** reproduce it. Cast to
90
- `(string)` before indexing. See `mvc-data-access-patterns`.
91
- - **`.tool--wide` did nothing.** `assets/css/style.css` documented the class as escaping the
92
- 960px `.app-content` cap "via `:has()`", but that rule was never written. Added
93
- `.app-content:has(.tool--wide) { max-width: 100% }` — which also makes the pre-existing
94
- `mvc/errors` pages genuinely full-width for the first time.
95
- - **Put Talos page CSS in `style.css`, not a page-local `<style>` block.** The Contracts page
96
- **returns early** for list mode, so a `<style>` block placed after that return applied to
97
- the calculator only and the list rendered with no CSS at all.
98
- - **Voice multipliers (0.25 / 0.50) are UNVALIDATED** — supplied second-hand, never measured,
99
- and possibly inverted. Flagged in the UI. **Do not quote voice from them.**
65
+ - **`App_Database::fetchOne()` is not an existence test** — it 500s on zero rows. See [mvc-data-access-patterns](./mvc-data-access-patterns.md); the original `onboarding/post.php` carried this exact bug and would have fataled on the first save anyone attempted, which is good evidence nobody ever completed an onboarding through it.
66
+ - **This app runs PHP 8.5 and escalates deprecations to fatals** — `$arr[$row['nullableCol']]` is fatal even inside `isset()`. Local dev on PHP 8.2 does **not** reproduce it. Cast to `(string)` before indexing. See `mvc-data-access-patterns`.
67
+ - **`.tool--wide` did nothing.** `assets/css/style.css` documented the class as escaping the 960px `.app-content` cap "via `:has()`", but that rule was never written. Added `.app-content:has(.tool--wide) { max-width: 100% }` — which also makes the pre-existing `mvc/errors` pages genuinely full-width for the first time.
68
+ - **Put Talos page CSS in `style.css`, not a page-local `<style>` block.** The Contracts page **returns early** for list mode, so a `<style>` block placed after that return applied to the calculator only and the list rendered with no CSS at all.
69
+ - **Voice multipliers (0.25 / 0.50) are UNVALIDATED** — supplied second-hand, never measured, and possibly inverted. Flagged in the UI. **Do not quote voice from them.** Superseded for per-interaction voice by the `voiceCostPerMinute` factor (see pricing-cogs-model).
70
+ - **`pricingModel` is an enum/FIELD_LIST — validate it in PHP before save.** MySQL silently stores **NULL** for an unknown enum value, so `contracts/post.php` must check the incoming value against the allowed set (`PER_USER`/`PER_INTERACTION`) before saving, and re-validate the locked stored value on a signed row. (General 2.0 rule: `security.md` "Enum columns need validating in PHP".)
71
+ - **Security — two HIGH fixed on the contract page (cso, 2026-09-17):** (a) a signed contract could **flip its `pricingModel` on resubmit** to dodge the other model's minimum-field validation — the "locked at signing" override must run **before** per-model validation; (b) `clientIdentifier` (the AWS cost/usage join key) was only readonly client-side — it **must be re-pinned server-side** once signed, or a resubmit can misattribute a tenant.
100
72
  - **`db_team`** is read/write on Contracts and Settings, read-only on the dashboards.
101
- - **Chart.js colours must come from theme tokens, and a live theme switch needs
102
- `chart.update()`.** These pages now read series/axis/tick/gridline colours via
103
- `TogaTheme.color('--c-…')` inside a `paint()` registered with `TogaTheme.onChange(paint)`,
104
- then call `chart.update()` — `update()` clears Chart.js's resolver cache, so without it a
105
- `Chart.defaults` change does not reach charts that already exist. Chart.js otherwise defaults
106
- axis/tick text to a dark colour that is unreadable in dark mode. See
107
- [App-Wide Colour Theme](./theme-light-dark.md).
108
- - **These pages keep an explicit `--c-bs-*` token group** because they were authored against
109
- Bootstrap's palette (`#0d6efd`, `#6c757d`, `#495057`, `#dc3545`, `#198754`…), which is close
110
- to but not the TOGA brand palette — folding them into `--c-primary` visibly restyles them.
111
- - **Open (dark mode):** the error toast measures ~2.4:1 (white on `--c-danger`) and likely
112
- wants a fixed dark fill; the Pricing Dashboard target-band dataset never had a
113
- `backgroundColor` (pre-existing authoring slip) so the band shades rather than tints; the
114
- three chart pages cannot be statically proven light-identical and still need a browser pass.
115
-
116
- ## Security
117
- - `config.*.ini` carries committed plaintext secrets (team-accepted, see architecture Known
118
- issues) — **all still owe rotation**. Config keys referenced by name only
119
- (`[api] talos_backend_url` / `talos_backend_key`); no values recorded. Do not add secrets.
73
+ - **Chart.js colours must come from theme tokens, and a live theme switch needs `chart.update()`.** These pages now read series/axis/tick/gridline colours via `TogaTheme.color('--c-…')` inside a `paint()` registered with `TogaTheme.onChange(paint)`, then call `chart.update()` — `update()` clears Chart.js's resolver cache, so without it a `Chart.defaults` change does not reach charts that already exist. Chart.js otherwise defaults axis/tick text to a dark colour that is unreadable in dark mode. See [App-Wide Colour Theme](./theme-light-dark.md).
74
+ - **These pages keep an explicit `--c-bs-*` token group** because they were authored against Bootstrap's palette (`#0d6efd`, `#6c757d`, `#495057`, `#dc3545`, `#198754`…), which is close to but not the TOGA brand palette — folding them into `--c-primary` visibly restyles them.
75
+ - **Open (dark mode):** the error toast measures ~2.4:1 (white on `--c-danger`) and likely wants a fixed dark fill; the Pricing Dashboard target-band dataset never had a `backgroundColor` (pre-existing authoring slip) so the band shades rather than tints; the three chart pages cannot be statically proven light-identical and still need a browser pass.
76
+ - **Security:** `config.*.ini` carries committed plaintext secrets (team-accepted, see architecture Known issues) — **all still owe rotation**. Config keys referenced by name only (`[api] talos_backend_url` / `talos_backend_key`); no values recorded. Do not add secrets.
120
77
 
121
- ## Change history
122
- - 2026-08-04 — Tokenised these pages for the new app-wide light/dark theme: page-local styles
123
- now use `--c-*` tokens (Talos pages keep their own `--c-bs-*` Bootstrap ramp), and the three
124
- Chart.js pages read colours via `TogaTheme.color()` + repaint on `TogaTheme.onChange()` with
125
- `chart.update()`. Recorded the dark-mode open items (error-toast contrast, missing
126
- target-band `backgroundColor`). Not committed. (jcardinal)
127
- - 2026-08-04 — Restructured to **Contracts / Pricing Dashboard / Usage / Pricing Settings**. New Contracts pages replace Onboarding with a real PROSPECT/ACTIVE/CHURNED lifecycle (signed contracts churn, never hard-delete — `TalosPricingBands` cascades; post-signature edits append to `TalosContractAmendments`), plus actual-vs-estimate variance, month history chart and one-click apply-recommended-org-fee. Settings replaces the Cost Factors editor with measured rows read-only (`UPDATE ... AND isDerived = 0`). Benchmarks page deleted. Pricing Dashboard rebuilt for sales leadership. Fixed `usage.php clientOptions()` reading an always-empty `TalosClients` (selector rendered empty) → `GET /api/usage/clients` with Team-DB fallback; added cost-intensity panel. Estimator moved to token unit price + workload profiles. Wrote the missing `.app-content:has(.tool--wide)` rule and consolidated Talos CSS into `style.css`. (jcardinal)
128
- - 2026-06-29 — Built the Talos Pricing UI: nav folder + 4 pages (Onboarding w/ live JS estimate and contract-signing that locks the band schedule into `TalosClients`+`TalosPricingBands` in one txn; read-only Pricing Dashboard + Usage Benchmarks; technical-only Cost Factors editor) and `App_Talos_Estimator` (reads `TalosCostFactors`, derives conversations/user so sales need not enter it). Reads Team DB via new `db_team` connection. Offerings CHAT/VOICE_TO_VOICE/NATURAL_VOICE = 1.0/0.25/0.50 (voice ambiguous, deferred). (jcardinal)
78
+ ## Related
79
+ - [App-Wide Colour Theme](./theme-light-dark.md)
80
+ - [Persona-gated navigation](./persona-gated-navigation.md)
81
+ - [Tools MVC — data-access patterns](./mvc-data-access-patterns.md)
82
+ - [Tools architecture](../architecture.md)
83
+ - [Talos architecture (2.0)](../../../2.0/apps/talos/architecture.md)
84
+ - [Pricing COGS model (2.0)](../../../2.0/apps/talos/features/pricing-cogs-model.md)
85
+ - [Talos pricing automation (worker2)](../../../2.0/apps/worker2/features/talos-pricing-automation.md)
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-15
9
+ updated: 2026-09-17
10
10
  owners: ["dfranks", "bala", "jcardinal", "mhammontree", "snaredla", "rgirish"]
11
11
  files:
12
12
  - worker/crons/toga2/netsuite/common_sync_togasupply.php
@@ -24,6 +24,7 @@ files:
24
24
  - dbchanges2/Client/2026-09-02b - NetsuiteSyncCursorRename.sql
25
25
  - test/@srija/Elite Testing/Service Requests/test_sync_togasupply_elite_section.php
26
26
  - test/@srija/Elite Testing/Service Requests/test_diagnose_togasupply_elite.php
27
+ - dbchanges2/_modules/netsuite/2026-09-17a - StaleSalesOrderRetirementAcl.sql
27
28
  related:
28
29
  - ../architecture.md
29
30
  - ../../../2.0/apps/_underscore/features/shipping-carriers-and-accounts.md
@@ -34,6 +35,8 @@ related:
34
35
  - ../../../clients/elite/features/netsuite-togasupply-sync.md
35
36
  - ../../library/features/netsuite-item-class-sync.md
36
37
  - ../../../clients/compass-usa/features/sales-order-line-renumbering.md
38
+ - ../../../2.0/apps/_underscore/features/acl-permission-chain.md
39
+ - ../../../2.0/apps/_underscore/features/sales-order-purchase-order-bridge-direction.md
37
40
  ---
38
41
 
39
42
  ## Summary
@@ -97,6 +100,24 @@ its IDLE write. **There is no timeout, memory cap, or resource limit anywhere in
97
100
  in prod* (see below), never *tune the window size down*. (Corollary: a window that has grown back to
98
101
  the cap is positive evidence the section is completing.)
99
102
 
103
+ #### Reading `NETSUITE_EXECUTION_MODE_<SECTION>` at a glance (2026-09-17)
104
+
105
+ The value is `<seconds>-<STATE>`. Three readings, and only the first is a real freeze:
106
+
107
+ - **`1-RUNNING`** (window collapsed ÷3 to 1s) = throwing on **every** run = real freeze. Go read the
108
+ error (`Logs.Event` by clientId → file:line → `Logs_<Client>.Api` on the transactionId).
109
+ - **A large-window `*-RUNNING`** (e.g. `864000-RUNNING`, `288000-RUNNING`) = healthy mid-run **or** a
110
+ deliberate pause/backfill — **NOT** stuck. Do not "fix" it.
111
+ - **`IDLE` + cursor frozen exactly at a reset value + NO error events = the section toggle is off**
112
+ (`IS_ENABLED_INTEGRATION_<SECTION> = false` in the wrapper). Resetting the cursor does **nothing**
113
+ while the toggle is off. Worked example: NYCHH SALES_ORDERS looked "stuck" after a cursor reset to
114
+ 2020-01-01, but `sync_togasupply_hh.php:33` had `IS_ENABLED_INTEGRATION_SALES_ORDERS = false`;
115
+ re-enabling it (line 33 `true`) made the cursor advance immediately.
116
+
117
+ Before calling any section stuck, **check for error events first.** The developer routinely rewinds
118
+ cursors (and re-enables feeds) after fixes and to prioritize data, so a cursor sitting years back
119
+ (e.g. NYCHH IF/receipts rewound to 2023) can be an intentional backfill, not a fault.
120
+
100
121
  **Where the error is: prod `Logs.Issue` / `Logs.Event`, NOT Sentry.** Read worker errors from the
101
122
  production log DB by default — you do not need a developer to point you there. The base `Logs`
102
123
  schema (prod-logs cluster) holds `Issue` (deduped, with `errorMessage` + `trace`) and `Event` (each
@@ -711,6 +732,123 @@ nested count:
711
732
  line the fulfillment referenced, the match failed, and the section fail-loud-froze. The flat list
712
733
  replaces the truncated nested one before match/reconstruct.
713
734
 
735
+ ### Retiring the stale SalesOrder a reclassified TransferOrder leaves behind (2026-09-17, NOT YET DEPLOYED)
736
+
737
+ **Turning transfer-order detection on for an existing client strands every order it already
738
+ imported as a SalesOrder.** The same NetSuite order now syncs to a `TransferOrder`, but the old
739
+ `SalesOrder` is never cleaned up. Its lines still carry fulfillments, so inventory **double-counts**
740
+ and `Items._qtyOnHand` goes negative. Measured on Elite: **218** SalesOrders duplicate a
741
+ TransferOrder on `c_netsuiteInternalSalesOrderId`, and **217 of 217** matched pairs are identical on
742
+ order number, line count *and* total quantity.
743
+
744
+ `App_Api_Toga2::retireStaleSalesOrderForTransferOrder()` (new, `private static`) is called at the
745
+ **END** of `syncTransferOrderFromNetsuite()` so the TO and its lines exist first. Per SO line:
746
+
747
+ 1. move `ItemFulfillmentItems`: `salesOrderItemId` → `transferOrderItemId`,
748
+ 2. move `PurchaseOrderItems_SalesOrderItems` → `PurchaseOrderItems_TransferOrderItems`,
749
+ 3. `DELETE` the `SalesOrderItem`;
750
+
751
+ then move `SalesOrders_PurchaseOrders` → `PurchaseOrders_TransferOrders` and `DELETE` the
752
+ `SalesOrder`. **Move before delete, always** — the bridge FKs are `RESTRICT`.
753
+
754
+ **The two fulfillment-parent columns are mutually exclusive, which is what makes the move safe.**
755
+ Prod check: 391 rows TO-side, 293 SO-side, **0 with both, 0 with neither**.
756
+
757
+ **It throws — never skips — on:** more than one SalesOrder for the NetSuite id; a TO with no
758
+ lines; an SO line with no matching TO line; a line carrying `InvoiceItems` (**`InvoiceItems` has no
759
+ `transferOrderItemId`**, so they physically cannot be moved); or any bridge whose existence check
760
+ returns no `totalRecordCount` (see the ACL shape below). Consistent with the 2026-08-27 fail-loud
761
+ policy.
762
+
763
+ **Bridge direction is PO-FIRST, confirmed from NYCHH data rather than guessed.** The naming rule is
764
+ `<source>_<created from it>`. `Client_Nychh` populates `PurchaseOrderItems_TransferOrderItems` (98
765
+ rows) and `PurchaseOrders_TransferOrders` (45); the TO-first siblings are **0 in every client**. Same
766
+ rule as the [SO↔PO bridge direction doc](../../../2.0/apps/_underscore/features/sales-order-purchase-order-bridge-direction.md).
767
+
768
+ **⚠ It needs the three-layer ACL grant first.** Without `AclFieldPermissions` on records 29/325/329
769
+ the bridge existence checks return **200 + `WZ-1` with no `meta.totalRecordCount`** and the method
770
+ throws while blaming record ACL — the exact trap documented on the
771
+ [ACL permission chain](../../../2.0/apps/_underscore/features/acl-permission-chain.md). The
772
+ fleet-wide grant is `dbchanges2/_modules/netsuite/2026-09-17a - StaleSalesOrderRetirementAcl.sql`.
773
+
774
+ **Open, carried forward:**
775
+ - Elite SalesOrder **240734** has 4 `InvoiceItems` and **will throw** on retirement. Deliberate
776
+ fail-loud; it blocks the backfill until resolved by hand.
777
+ - **11 of the 218** stale SalesOrders have no matching TransferOrder (4 are TOGa-created `SA1000xx`
778
+ with no NetSuite id). Manual review.
779
+ - `getTransferOrderItemsByUuid()` (`toga2.php` ~L4555) has **no pagination** — it caps at
780
+ `recordsPerPage` 1000, unlike its paginated sibling `getSalesOrderItemsBySalesOrderUuid()`. It is
781
+ load-bearing for this method's safety. Skipped for now because Elite TOs are 1–5 lines; flagged by
782
+ php-reviewer.
783
+
784
+ ### ⚠ TWO code paths create `PurchaseOrderItems` — only one stamped `fulfillmentType` (fixed 2026-09-17)
785
+
786
+ `fulfillmentType` was stamped by `syncPurchaseOrderFromNetsuite()` (from the order-level
787
+ `isDropShipPurchaseOrder`) but **not** by the **customer-PO block inside
788
+ `syncSalesOrderFromNetsuite()`**, at three POST sites. Those customer POs are built from the NetSuite
789
+ sales order's `otherRefNum` against the synthetic Agilant vendor, so they carry a `customerId` but a
790
+ **NULL `c_netsuiteInternalPurchaseOrderId`** — which is how to spot them. **1,038 of 1,113** NULL
791
+ `fulfillmentType` PO lines came from this path, so **no cursor rewind would ever have fixed them**;
792
+ the rewind only replays the other path.
793
+
794
+ Fix: stamp `fulfillmentType` (inherited from the SO line, exactly as `cost` already is) at all three
795
+ create sites, plus a **backfill PUT on the matched-existing-line branch** so pre-existing rows fill
796
+ in on the next sync. Gated by `isset()` on the payload key so non-opted-in clients send nothing, and
797
+ the backfill uses `property_exists` (not `??`) per this file's established ACL-omission rule — an
798
+ ungranted field is **absent**, not null.
799
+
800
+ **Lesson: before concluding "the backfill is lagging", grep for every writer of the column.** A
801
+ second, un-stamped create path looks identical to a stale cursor from the data side.
802
+
803
+ ### ⚠ A stale child list read at run-start causes FK 1451 on a delete LATER IN THE SAME RUN (fixed 2026-09-17)
804
+
805
+ `NETSUITE_EXECUTION_MODE_ITEM_FULFILLMENTS` decayed `864000 → 1186 → 396`, stuck `RUNNING`, with one
806
+ item (`b96515b3`) failing five consecutive runs ~every 5 minutes. **Not self-correcting.**
807
+
808
+ ```
809
+ EV-11 ... Error #: 1451 Cannot delete or update a parent row: a foreign key constraint fails
810
+ (Client_Elite.ItemFulfillmentItems_TrackingNumbers ...)
811
+ ```
812
+
813
+ **Root cause is ordering inside a single run, not stale data between runs.** The delete reads an
814
+ item's tracking rows from `$itemFulfillment`, fetched at the **top** of the run — but the tracking
815
+ pass **later in the same run** creates new tracking rows against those same items. The run-start
816
+ snapshot misses them, the child delete skips one, and the item delete hits the FK. Prod proof:
817
+ tracking `0ddc9513` created at **03:31:18**, the item delete failed at **03:31:21** — three seconds
818
+ apart.
819
+
820
+ Fix, applied at **both** item-level delete sites: **re-read the item's tracking rows live**
821
+ (`GET /v2/item-fulfillment-item-tracking-numbers` joined on `ItemFulfillmentItems.uuid`) immediately
822
+ before deleting, instead of trusting the run-start snapshot — the same re-fetch pattern this file
823
+ already uses for the single-tracking-number fan-out. A `totalRecordCount` guard was added to both.
824
+
825
+ **Scope was confirmed, not assumed:** every FK-1451 failure that day was on
826
+ `/v2/item-fulfillment-items/` — **zero** on units or tracking rows — so the unit-level lists were
827
+ deliberately left alone.
828
+
829
+ The exception was **not** swallowed. `send()` throws by default; the throw aborted the run and left
830
+ the mode `RUNNING`. That is the designed fail-loud behaviour working correctly.
831
+
832
+ **General rule for this engine: any list used to delete children must be re-read immediately before
833
+ the delete if ANY later pass in the same run can create more of them.** A run-start snapshot is only
834
+ safe for read-only use.
835
+
836
+ ### ⚠ NULL `fulfillmentType` on SO lines can be CORRECT ROUTING, not a sync bug (2026-09-17)
837
+
838
+ "684 `SalesOrderItems` have NULL `fulfillmentType`" looked like a defect and was not. Ruled out in
839
+ order — record these so nobody re-walks them:
840
+
841
+ 1. backfill lag — no,
842
+ 2. the `property_exists` gate — no,
843
+ 3. ACL on the field — grants exist (`recordFieldId` **2594/2595**, roleId 3, `isWritable = 1`),
844
+ 4. a cursor timezone theory — **wrong**; the code correctly uses `America/New_York`.
845
+
846
+ **Actual reason:** those orders are now classified as **Transfer Orders** (`$0` + `holdInvoice`), so
847
+ the sync routes them to `syncTransferOrderFromNetsuite()` and they never reach the sales-order path
848
+ that stamps the field. The rows are stranded leftovers — exactly what the retirement method above
849
+ cleans up. **When a per-line field is NULL on a set of orders, check the routing branch before the
850
+ writer.**
851
+
714
852
  ### Custom field contract (hardcoded in `common_sync_togasupply.php`)
715
853
 
716
854
  The engine requests these `c_` fields by literal name per route — every synced client must have
@@ -1561,6 +1699,39 @@ library (or vice versa) crashes GroWrk and Adyen on their next sync run.
1561
1699
  SalesOrders" branch, classify the order authoritatively (`App_Api_Netsuite_Rest::fetchSalesOrderByIdFull`
1562
1700
  + `self::isTransferOrder`) and **return early** when it is a transfer order, gated on
1563
1701
  `IS_ENABLED_INTEGRATION_TRANSFER_ORDERS`. Existing TO-billed invoices were deleted by the developer.
1702
+ - **⚠ The invoice's billed-SO lookup must be PER-CLIENT scoped — the `==1` guard false-throws for a
1703
+ client that keeps two SalesOrders per NetSuite id (fixed + deployed 2026-09-17, Compass).**
1704
+ `syncInvoiceFromNetsuite` (`library/app/api/toga2.php` ~L3326-3339) looked up the billed sales order
1705
+ by `c_netsuiteInternalSalesOrderId` **only** and required `meta->totalRecordCount == 1`. Compass keeps
1706
+ **two** `SalesOrders` rows for one NetSuite id — the Office-Depot-created `customerId=1` order and the
1707
+ NetSuite-synced `customerId=3` order — so the lookup returned 2, failed the `==1` check, and the
1708
+ fail-loud dependency guard threw *"refusing to import invoice … its billed sales order … is not
1709
+ imported in toga"* (prod `Logs.Issue` 792, clientId 2, INVOICES stuck `96000-RUNNING`, cursor
1710
+ ~2026-08-20) even though the correct order (id 97932, `customerId=3`, `locationId=615286`) was present.
1711
+ **Fix:** add a per-client scope block to this lookup **mirroring** `syncSalesOrderFromNetsuite` (~L974-987)
1712
+ and `syncItemFulfillmentFromNetsuite` (~L5197-5204) — Compass adds `SalesOrders.locationId=615286` +
1713
+ `customerId=3`; Prudential adds the `Customers` join + `CLIENT_UUID_PRUDENTIAL_CUSTOMER`; no other client
1714
+ changed, no else/default, and the guard still throws for a genuinely-missing SO. PHP 7.2-safe
1715
+ (`$options['join'] ?? []`), signature unchanged.
1716
+ - **⚠ OPEN follow-up (own ticket): six OTHER clients also keep two SalesOrders per NetSuite id, and the
1717
+ fix does NOT scope them.** A read-only sweep found the same dual-SO pattern in **Growrk (2789), Canon
1718
+ (2890), Endeavorhealth (4828), Spglobal (2893), Browardsheriff (160), Masonite (17)** (Nychh/Elite/Quad/Sbasite
1719
+ = 0; Compass 28377). Dormant today — only Compass is actually throwing the invoice guard (0 "refusing to
1720
+ import invoice" events for any other client in the last 5 days) — but if any of those six ever bills an
1721
+ invoice against a duplicated SO it will false-throw the same way, and **their duplicates have a DIFFERENT
1722
+ cause than Compass's Office-Depot dual-source.** Also a data-quality question: why do six non-Compass
1723
+ clients have duplicate SalesOrders rows at all.
1724
+ - **⚠ ITEM_RECEIPTS auto-creates a warehouse Location — the client's `LocationTypes` MUST have a
1725
+ "Warehouse" row or it EV-12-freezes (fixed 2026-09-17, Prudential).** `syncItemReceiptFromNetsuite`
1726
+ (`library/app/api/toga2.php`) `POST /v2/locations` with `locationType.uuid = WAREHOUSE_LOCATION_TYPE_UUID
1727
+ = 78c24f51-b33b-438b-e3f2-b6232d9ecb75` (:3997). `Client_Prudential.LocationTypes` had only a "Shipping"
1728
+ row — no "Warehouse" — so the reference didn't resolve → **EV-12** (prod `Logs.Issue` 757, clientId 5,
1729
+ ITEM_RECEIPTS `1-RUNNING`, cursor stuck 2026-09-14 15:12:43; confirmed via `Logs_Prudential.Api` `POST
1730
+ /v2/locations` 400). **Fix:** guarded/re-runnable INSERT of the Warehouse `LocationTypes` row using the
1731
+ **same literal uuid every client uses** (the documented shared-lookup-uuid exception to "uuids must be
1732
+ random"; `Client_Nychh` has it as id 2) —
1733
+ `dbchanges2/Client_Prudential/2026-09-17 - SeedWarehouseLocationType.sql`. **The live `LocationTypes`
1734
+ column is `name`, NOT the older `type`.** The section self-heals once the row exists (no manual mode reset).
1564
1735
  - **⚠ ROOT CAUSE of the duplicate `Items`: the inventory-adjustment item-create OMITTED the catalog
1565
1736
  (fixed 2026-08-31).** `getCreateItem` (`toga2.php:6233`) and the fulfillment create (~4951) stamp
1566
1737
  `'catalog' => ['name' => 'Agilant']`; `syncInventoryAdjustmentFromNetsuite`'s item-create did **not** →
@@ -1653,6 +1824,38 @@ library (or vice versa) crashes GroWrk and Adyen on their next sync run.
1653
1824
  [NetSuite Sync Alert Monitor](../../library/features/netsuite-sync-alert-monitor.md).
1654
1825
 
1655
1826
  ## Change history
1827
+ - 2026-09-17 — **Cross-client sync-freeze debugging (Compass / Prudential / NYCHH / Elite).**
1828
+ (1) **Compass INVOICES** unfrozen (`96000-RUNNING`, `Logs.Issue` 792): `syncInvoiceFromNetsuite`'s
1829
+ billed-SO lookup keyed on `c_netsuiteInternalSalesOrderId` with a `==1` guard, but Compass keeps two
1830
+ `SalesOrders` per NetSuite id (ODP `customerId=1` + synced `customerId=3`) → lookup returned 2 →
1831
+ false-throw. Added a per-client scope block mirroring the SO/IF paths (Compass `locationId=615286`+`customerId=3`;
1832
+ Prudential `Customers` join). php-reviewer + cso clean, deployed. **Open follow-up (own ticket):** six other
1833
+ clients (Growrk/Canon/Endeavorhealth/Spglobal/Browardsheriff/Masonite) have the same dual-SO pattern from a
1834
+ different cause and are NOT scoped by this fix — dormant today. (2) **Prudential ITEM_RECEIPTS** unfrozen
1835
+ (`Logs.Issue` 757, EV-12): the receipt sync auto-creates a warehouse Location, but `Client_Prudential.LocationTypes`
1836
+ had no "Warehouse" row → seeded it via `dbchanges2/Client_Prudential/2026-09-17 - SeedWarehouseLocationType.sql`
1837
+ (shared-lookup-uuid; live column is `name`), deployed, section self-healed. (3) Recorded the
1838
+ **execution-mode reading key** (`1-RUNNING` = real freeze; large-window `*-RUNNING` = healthy/paused;
1839
+ `IDLE`+frozen-at-reset+no-errors = toggle off) — NYCHH SALES_ORDERS looked stuck but was toggled off at
1840
+ `sync_togasupply_hh.php:33`; re-enabling advanced the cursor. (jcardinal)
1841
+ - 2026-09-17 — Four importer changes, all found on Elite. (1) New
1842
+ `App_Api_Toga2::retireStaleSalesOrderForTransferOrder()` retires the stale `SalesOrder` left
1843
+ behind when an order is reclassified as a TransferOrder — 218 Elite duplicates were
1844
+ double-counting inventory and driving `_qtyOnHand` negative; it moves fulfillments and PO bridges
1845
+ to the TO side before deleting, and throws on `InvoiceItems` (no `transferOrderItemId` exists).
1846
+ Bridge direction confirmed **PO-first** from NYCHH row counts. **NOT yet deployed.** (2) The
1847
+ customer-PO block inside `syncSalesOrderFromNetsuite()` never stamped `fulfillmentType` at its
1848
+ three POST sites — 1,038 of 1,113 NULL PO lines came from that second writer, so no cursor rewind
1849
+ could fix them; added the stamp plus a backfill PUT on the matched-line branch. (3) ITEM_FULFILLMENTS
1850
+ froze on **FK 1451 / EV-11** because the tracking-row list was read at run start while a later pass
1851
+ in the **same run** created more rows; both item-level delete sites now re-read tracking live before
1852
+ deleting. (4) Recorded that a NULL per-line `fulfillmentType` can be correct **routing** (the order is
1853
+ now a Transfer Order) rather than a sync bug, with the four dead ends already ruled out. Also noted the
1854
+ PO-sync unfreeze needed a three-layer ACL grant — the field layer fails as a silent 200, see the
1855
+ [ACL permission chain](../../../2.0/apps/_underscore/features/acl-permission-chain.md).
1856
+ php-reviewer + silent-failure-hunter clean; one open warning (`getTransferOrderItemsByUuid()` has no
1857
+ pagination). (rgirish)
1858
+
1656
1859
  - 2026-09-15 — Added the **full resync recipe** for a deep cursor rewind (the procedure for
1657
1860
  backfilling a newly-stamped item field): the six `NETSUITE_LAST_SYNC_CURSOR_<SECTION>` keys live in
1658
1861
  the **client** DB, the id half **must be `0`** or the seek skips records at the same datetime, the
@@ -17,6 +17,7 @@ files:
17
17
  - dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql
18
18
  - dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql
19
19
  - dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql
20
+ - dbchanges2/_modules/netsuite/2026-09-17a - StaleSalesOrderRetirementAcl.sql
20
21
  ---
21
22
 
22
23
  ## Summary
@@ -127,6 +128,92 @@ Two distinct failure shapes, and the second is a page-killer:
127
128
  endpoint takes the **entitlement** uuid precisely to avoid requesting the joined
128
129
  `subscription.uuid` and betting the whole entitlements page on one more grant.
129
130
 
131
+ ### ⚠⚠ Record access + ZERO readable fields = HTTP **200**, `WZ-1`, and **NO `meta.totalRecordCount`**
132
+
133
+ The third layer of the chain fails in a shape nothing else does, and it costs hours because
134
+ **nothing lands in the API error log — the call succeeded.**
135
+
136
+ With `AclRecordPermissions` + expression + logic group all correct, but **no `AclFieldPermissions`
137
+ rows at all** for that role × record, a GET returns:
138
+
139
+ - HTTP **200** (not 403, not `EZ-2`),
140
+ - message **`WZ-1: "There are no fields in which you are authorized to read"`**,
141
+ - and **`meta.totalRecordCount` is OMITTED entirely** from the envelope.
142
+
143
+ That last point is the killer. Fail-loud importer code commonly guards on
144
+ `if (!isset($response->meta->totalRecordCount)) { throw ... }` as its "the existence check did not
145
+ run" test — so a pure field-layer gap surfaces as that throw, with a message blaming record ACL,
146
+ while the record ACL is in fact fine.
147
+
148
+ **Distinguish the three failure shapes before touching any grant:**
149
+
150
+ | Shape | Missing layer |
151
+ |---|---|
152
+ | 403 `EZ-1` / `No ACL Logic Groups defined for ACL Record Permissions` | record permission or logic group |
153
+ | 403 `EZ-2` naming specific fields (incl. a `uuid` you never asked for) | *some* field grants exist, the named one does not |
154
+ | **200 + `WZ-1` + no `meta.totalRecordCount`** | **`AclFieldPermissions` is completely empty for that role × record** |
155
+
156
+ **Worked case (Elite, 2026-09-17).** Elite's NetSuite PO sync was dead for days:
157
+ `NETSUITE_EXECUTION_MODE_PURCHASE_ORDERS` sat at `1-RUNNING` with 51 lifetime occurrences of
158
+ `linkPurchaseOrderToTransferOrder: PurchaseOrders_TransferOrders existence check returned no
159
+ totalRecordCount (API role likely lacks ACL read/write on this bridge)`. It was **misdiagnosed
160
+ twice**: the first pass added `AclRecordPermissions` for records 29/325/329 plus `allowDelete` on
161
+ 14/15, verified all five rows present with logic groups — and it still failed, because
162
+ `Client_Elite` had **zero** roleId 3 rows in `AclFieldPermissions` for those bridges.
163
+ `Client_Nychh` had all six, which is exactly why linking worked there.
164
+
165
+ The working set that fixed it (mirrors `Client_Nychh`):
166
+
167
+ | `Core.Records` id | route | granted `RecordFields` (roleId 3) |
168
+ |---|---|---|
169
+ | **325** | `purchase-orders-transfer-orders` | 2210 `uuid`, 2211 `purchaseOrderId`, 2212 `transferOrderId` |
170
+ | **329** | `purchase-order-items-transfer-order-items` | 2226 `uuid`, 2227 `purchaseOrderItemId`, 2228 `transferOrderItemId` |
171
+ | **29** | `item-fulfillment-items` | 366 `uuid`, 367 `itemFulfillmentId`, 368 `salesOrderItemId`, 369 `quantity`, 2151 `transferOrderItemId` |
172
+
173
+ **`id` (2209 / 2225 / 365) is deliberately NOT granted** — NYCHH does not grant it either, and the
174
+ sync joins on `uuid` throughout precisely because the numeric `id` is not ACL-readable. Do not "fix"
175
+ its absence.
176
+
177
+ Result after deploy: `PurchaseOrders_TransferOrders` went **0 → 17** rows and
178
+ `PurchaseOrderItems_TransferOrderItems` **0 → 28** (populated for the first time ever); the PO mode
179
+ recovered to `864000-IDLE` and the cursor moved 2026-02-03 → 2026-03-28.
180
+
181
+ **Rule: when you add a record grant, add the field grants in the same migration.** A record
182
+ permission with no field permissions is not a partial grant — it is a grant that reads as a
183
+ successful, empty, count-less response.
184
+
185
+ ### ⚠ A multi-client ACL migration must SELF-HEAL — every tenant is broken differently
186
+
187
+ Do not write a fleet-wide ACL migration as "insert the rows the reference client has." Surveying
188
+ five `netsuite` clients on 2026-09-17 found **five different broken states** for the same set of
189
+ records:
190
+
191
+ | Tenant | State found |
192
+ |---|---|
193
+ | **NYCHH** | complete and correct — the reference |
194
+ | **Elite** | records 325/329 missing entirely |
195
+ | **GroWrk** | 325/329 missing **and** record 15 has a permission with **zero logic groups** (silently denies) |
196
+ | **Prudential** | 325/329 exist **twice each** (duplicate permission rows), no expressions, no logic groups |
197
+ | **Canon, Quad** | only 14/15, both with `allowDelete = 0` |
198
+
199
+ The six-step shape that survives all of them — every step `NOT EXISTS`-guarded and re-runnable:
200
+
201
+ 1. `UPDATE` `allowDelete = 1` on the records that already have permissions (14/15).
202
+ 2. `INSERT` `AclRecordPermissions` for the missing records (29/325/329).
203
+ 3. `INSERT` `AclRecordExpressions` `'all'` for **all five** records — not just the new three.
204
+ Prudential already had permissions on 325/329 with **no expression**.
205
+ 4. `INSERT` `AclLogicGroups` for **any** permission lacking one — this is what catches GroWrk's
206
+ orphan permission and Prudential's duplicates, which a "new records only" migration walks past.
207
+ 5. `INSERT` `AclLogicGroupExpressions` joining on **`MIN(id)` per record**, not on the expression
208
+ slug. Prudential has **three** `'all'` expressions on record 14; joining by slug alone fans out
209
+ into a cross product.
210
+ 6. `INSERT` `AclFieldPermissions` — the layer the first attempt missed (see the section above).
211
+
212
+ **The `MIN(id)` and orphan-logic-group guards came from real production rows, not from theory.**
213
+ Before writing a fleet migration, survey the tenants and let the worst state design the guards.
214
+ Applies to every module that ships to many clients (this one goes to all **22** clients carrying
215
+ `netsuite` in `dbchanges2/_modules.txt`).
216
+
130
217
  ### ⚠ Resolve `Core.RecordFields` ids by HARDCODED LITERAL in a `Client_*` migration — the subselect cannot run
131
218
 
132
219
  This **overrides** the general "resolve by subselect, never hardcode" rule *for Core lookups from a
@@ -771,6 +858,16 @@ hardcoded `Core.RecordFields` id literals instead of a subselect.
771
858
  and every repo is on the **same branch** so the generated model matches the DB.
772
859
 
773
860
  ## Change history
861
+ - 2026-09-17 — Added the **field-layer silent-200** failure shape: record access with **zero**
862
+ `AclFieldPermissions` rows returns HTTP **200** carrying `WZ-1` and **omits**
863
+ `meta.totalRecordCount`, so fail-loud code that guards on that key throws while blaming record
864
+ ACL — and nothing appears in the API error log because the call succeeded. Verified on
865
+ `Client_Elite` (records 29/325/329, roleId 3) after the bug was misdiagnosed twice as a record
866
+ permission gap; recorded the exact `RecordFields` granted (and that `id` is deliberately not).
867
+ Also added the **self-healing fleet migration** rule — a survey of five `netsuite` tenants found
868
+ five different broken states (missing rows, an orphan logic group, duplicate permissions,
869
+ `allowDelete = 0`), so a multi-client ACL migration must guard every step with `NOT EXISTS` and
870
+ join logic-group expressions on `MIN(id)` per record rather than on the expression slug. (rgirish)
774
871
  - 2026-09-17 — Added the **restrictive-expression-is-dead** rule: permissions are collected per role
775
872
  and **any** match authorizes, so a row-restricting expression on one role is cancelled by an `'all'`
776
873
  grant on another role the same user holds. Verified in `Client_Compass`: expression **235** on role
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-14
10
- owners: [snaredla, jcardinal, bala, apeterson]
9
+ updated: 2026-09-17
10
+ owners: [snaredla, jcardinal, bala, apeterson, rgirish]
11
11
  files:
12
12
  - _underscore/Model.php
13
13
  - _underscore/Model/Client/PurchaseOrder.php
@@ -21,6 +21,8 @@ files:
21
21
  - _underscore/Model/Quad/Item.php
22
22
  - _underscore/Model/Quad/VendorItem.php
23
23
  - _underscore/Model/Nychh/Unit.php
24
+ - _underscore/Model/Client/Item.php
25
+ - _underscore/Model/Elite/Item.php
24
26
  - dbchanges2/Client_Nychh/2026-09-10a - UnitsForPoTableViewRebaseAndTransferLocationField.sql
25
27
  - dbchanges2/Client/2026-08-11b - SalesOrderPurchaseOrdersField.sql
26
28
  - dbchanges2/Client/2026-08-25 - SalesOrderPurchaseOrdersFieldAllClients.sql
@@ -35,6 +37,7 @@ related:
35
37
  - ./sales-order-purchase-order-bridge-direction.md
36
38
  - ../../../../clients/nychh/features/location-shipping-addresses.md
37
39
  - ../../toga25-supply/features/transfer-orders-page.md
40
+ - ../../../clients/elite/features/inventory-quantities-drop-ship.md
38
41
  ---
39
42
 
40
43
  ## Summary
@@ -218,6 +221,26 @@ characters — that is the standard of evidence for touching a field that runs o
218
221
  > even for a client whose subclass overrides it. `_Model_Compass_SalesOrder` has exactly this in its
219
222
  > ApprovalDecision notification query. Late static binding only happens with `static::`.
220
223
 
224
+ ### ⚠ `_Model_Client_Item`'s four composites were `self::` until 2026-09-17 — 25 client models affected
225
+
226
+ A live instance of the `self::` trap above, now fixed, worth knowing because it silently changed
227
+ behaviour for every tenant.
228
+
229
+ `_Model_Client_Item`'s four **composite** calculated fields — `_qtyOnHand`, `_qtyAvailable`,
230
+ `_qtyBackordered`, `_qtyOnOrder` — built their SQL from the leaf `_qty*` methods using **`self::`**.
231
+ So a client subclass that overrode a *leaf* method (e.g. `_qtyReceived`) had the override **silently
232
+ ignored** by the inherited composites; the only way to make it stick was to re-declare all four
233
+ composites verbatim on the child. `_Model_Client_PurchaseOrderItem` already used `static::`.
234
+
235
+ Changed to **`static::`** on 2026-09-17, matching the PurchaseOrderItem model. **This affects all 25
236
+ client `Item` models:** any client already overriding a leaf method now has that override picked up
237
+ by the composites for the first time. **`Nychh` overrides `_qtyReceived` and `_qtyCommitted`, so it
238
+ is the one to watch.**
239
+
240
+ **Lesson for any shared base model:** write composites with `static::` from the start. A `self::`
241
+ composite does not fail — it quietly returns the parent's number, and the child's override looks
242
+ like it "did nothing."
243
+
221
244
  ## Filtering on a calculated field through the V2 API — it works, bare name only
222
245
 
223
246
  A calculated field **can** be used in a V2 `where`, and it is the cheapest way to cut a big list —
@@ -268,6 +291,14 @@ Keep the expression cheap, or expect callers to pay for it on every list.
268
291
  [save-cascade stored-field deadlocks](./model-save-parent-cascade-stored-field-deadlock.md).
269
292
 
270
293
  ## Change history
294
+ - 2026-09-17 — Recorded a live instance of the `self::` trap: `_Model_Client_Item`'s four composite
295
+ fields (`_qtyOnHand`, `_qtyAvailable`, `_qtyBackordered`, `_qtyOnOrder`) used **`self::`**, so a
296
+ client subclass overriding a *leaf* `_qty*` method was silently ignored unless it re-declared all
297
+ four composites. Switched to **`static::`**, matching `_Model_Client_PurchaseOrderItem`. This
298
+ changes behaviour for **all 25 client `Item` models** — `Nychh` (overrides `_qtyReceived` and
299
+ `_qtyCommitted`) is the one to watch. Found while adding Elite's drop-ship exclusion; see
300
+ [Elite inventory quantities](../../../clients/elite/features/inventory-quantities-drop-ship.md).
301
+ (rgirish)
271
302
  - 2026-09-14 — Added a **Filtering** section: a calculated field IS usable in a V2 `where` with the
272
303
  bare (non-table-prefixed) name, which contradicts a belief written into several frontend repos.
273
304
  Mechanism and cost figures cross-referenced to the api2 query contract rather than duplicated.