toga-ai 1.0.676 → 1.0.677

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.
Files changed (28) hide show
  1. package/knowledge/1.0/apps/library/INDEX.md +1 -1
  2. package/knowledge/1.0/apps/library/features/netsuite-suiteql-rest-shim.md +51 -3
  3. package/knowledge/1.0/apps/worker/INDEX.md +1 -1
  4. package/knowledge/1.0/apps/worker/features/forecast2-netsuite-reconciliation.md +65 -3
  5. package/knowledge/2.0/apps/_underscore/INDEX.md +3 -3
  6. package/knowledge/2.0/apps/_underscore/features/acl-permission-chain.md +53 -1
  7. package/knowledge/2.0/apps/_underscore/features/netsuite-rest-client.md +37 -6
  8. package/knowledge/2.0/apps/_underscore/features/surface-resolver.md +100 -10
  9. package/knowledge/2.0/apps/ai-bdr/INDEX.md +3 -3
  10. package/knowledge/2.0/apps/ai-bdr/features/landing-call-flow.md +59 -14
  11. package/knowledge/2.0/apps/ai-bdr/features/landing-chat-drawer.md +107 -52
  12. package/knowledge/2.0/apps/ai-bdr/features/security-landing-page.md +38 -23
  13. package/knowledge/2.0/apps/dbchanges2/INDEX.md +1 -1
  14. package/knowledge/2.0/apps/dbchanges2/features/rerunnable-additive-inserts.md +77 -4
  15. package/knowledge/2.0/apps/toga-blox/INDEX.md +2 -1
  16. package/knowledge/2.0/apps/toga-blox/features/talos-assistant.md +104 -21
  17. package/knowledge/2.0/apps/toga-blox/workflows/local-link-into-a-consumer-app.md +97 -0
  18. package/knowledge/2.0/apps/toga25-supply/INDEX.md +1 -0
  19. package/knowledge/2.0/apps/toga25-supply/features/side-navigation-and-default-route.md +125 -0
  20. package/knowledge/2.0/apps/worker2/INDEX.md +2 -2
  21. package/knowledge/2.0/apps/worker2/features/netsuite-opportunity-sync.md +22 -8
  22. package/knowledge/2.0/apps/worker2/features/netsuite-salesorder-open-orders-sync.md +203 -37
  23. package/knowledge/2.0/apps/worker2/workflows/running-worker2-locally.md +85 -10
  24. package/knowledge/INDEX.md +2 -2
  25. package/knowledge/clients/compass-usa/INDEX.md +1 -1
  26. package/knowledge/clients/compass-usa/workflows/granting-navigation-access.md +127 -5
  27. package/knowledge/sessions/2026-08-27-bdr-blox-talos-drawer-tcox.md +160 -0
  28. package/package.json +1 -1
@@ -16,7 +16,7 @@
16
16
  | [1.0 MVC Page Pattern & New-App Skeleton](features/mvc-page-pattern-and-app-skeleton.md) | This is the **reusable recipe for standing up a new 1.0 (`App_`) application** and for adding pages to one — the folder-based MVC routing, the page lifecycle, t | library/app/framework.php, library/app/frameworkindex.php, library/app/mvc.php, library/app/database.php, library/app/model.php, library/app/config.php |
17
17
  | [isFulfillable from NetSuite during Item Sync (Phase 1)](features/netsuite-item-isfulfillable-sync.md) | This is the **1.0 (Phase 1)** half of the `isFulfillable` feature: reading the NetSuite `isfulfillable` flag during item sync and stamping it onto the **Agilant | library/app/netsuite.php, library/app/api/toga2.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/backfill_isfulfillable_jul5.php |
18
18
  | [NetSuite SuiteQL/REST API Reference](features/netsuite-suiteql-api-reference.md) | General working reference for the Agilant NetSuite integration: how to authenticate, how SuiteQL behaves, and the confirmed schema of the tables/columns/codes w | library/app/api/netsuite/rest.php, library/ssl/netsuite_ec_key.pem, test/@dave/Junk Drawer/nsq.php |
19
- | [NetSuite SuiteQL/REST Shim — Field Semantics](features/netsuite-suiteql-rest-shim.md) | `App_Api_Netsuite_Rest` is the REST/SuiteQL replacement for the deprecated NetSuite SOAP toolkit. | library/app/api/netsuite/rest.php, library/app/netsuite.php |
19
+ | [NetSuite SuiteQL/REST Shim — Field Semantics](features/netsuite-suiteql-rest-shim.md) | `App_Api_Netsuite_Rest` is the REST/SuiteQL replacement for the deprecated NetSuite SOAP toolkit. | library/app/api/netsuite/rest.php, library/app/netsuite.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
20
20
  | [NetSuite Sync Alert Monitor (App_SystemMonitor_NetSuiteIntegration)](features/netsuite-sync-alert-monitor.md) | `App_SystemMonitor_NetSuiteIntegration` (`library/app/systemmonitor/netsuiteintegration.php`, title **"NetSuite Sync Alert"**) is a 1.0 system monitor that watc | library/app/systemmonitor/netsuiteintegration.php, worker/crons/infrastructure/system_monitors.php |
21
21
  | [Startech PC Matic B2B Sync (library)](features/startech-pcmaticb2b-sync.md) | `library/app/api/toga2.php` handles bidirectional ticket sync for PC Matic B2B between TOGaDesk 1.0 and TOGA 2.0. | library/app/api/toga2.php, library/app/api/startechticket.php, worker/crons/toga2/startech/common_import_supporting_records.php |
22
22
  | [App_Api_Toga2 — TOGa2 API Client & 1.0↔2.0 Sync Bridge](features/toga2-api-client-and-bridge.md) | `App_Api_Toga2` (`library/app/api/toga2.php`, ~8400 lines) is the **1.0-side client for the TOGa 2 (`_underscore`/api2) public API** *and* the home of the cross | library/app/api/toga2.php, worker/crons/toga2/aig/sync_togasupply_aig.php, worker/crons/toga2/wje/sync_togasupply_wje.php, test/@Mark/AIG/test_multi_email.php |
@@ -6,16 +6,18 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-25
10
- owners: [dfranks, jcardinal]
9
+ updated: 2026-08-27
10
+ owners: [dfranks, jcardinal, kyalamarthi]
11
11
  files:
12
12
  - library/app/api/netsuite/rest.php
13
13
  - library/app/netsuite.php
14
+ - worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php
14
15
  related:
15
16
  - netsuite-suiteql-api-reference.md
16
17
  - ../architecture.md
17
18
  - ../../worker/features/forecast2-netsuite-reconciliation.md
18
19
  - ../../../2.0/apps/_underscore/features/netsuite-rest-client.md
20
+ - ../../../../2.0/apps/worker2/features/netsuite-salesorder-open-orders-sync.md
19
21
  ---
20
22
 
21
23
  ## Summary
@@ -50,7 +52,11 @@ Forecast2 tables already store.
50
52
  re-maps/collides when SOAP-era records are edited. Verified: sales orders 106 orders / 344
51
53
  lines (2019–2029, incl. edited orders with line-id gaps), invoices + cash sales 62/62.
52
54
  `listSales` historically resolved line via `uniquekey → REST lineUniqueKey → line`; `tl.id`
53
- gives the same answer in bulk without the per-id GET.
55
+ gives the same answer in bulk without the per-id GET. **Re-confirmed at scale 2026-08-27:** the two
56
+ columns actually diverge on **2,061 of 4,387** lines (~47%), and `Forecast.OpenOrderItems.lineNumber`
57
+ (which stores REST `$line->line`) matched `transactionline.id` on **99 of 99** randomly sampled prod
58
+ rows plus all 7 multi-root orders, with zero join misses. So keying a NetSuite ↔ Forecast line join on
59
+ `linesequencenumber` silently mismatches about half the book — always join on `transactionline.id`.
54
60
 
55
61
  - **ShipItem lines have `costestimate = NULL`** (100% of 42K+ lines, 2025–26). In any profit
56
62
  expression `SUM(-foreignamount + costestimate)`, a NULL cost makes the whole term NULL and SQL
@@ -146,6 +152,35 @@ None — uniform across clients (NetSuite is a single shared account).
146
152
 
147
153
  ## Gotchas / known issues
148
154
 
155
+ - **`tl.location` was in this query, then wasn't, then was again — and the second removal was a MERGE,
156
+ not a decision.** History, because two separate tickets have now paid for it:
157
+ 1. TRUE-79078 (commit **31fadd77**) added `tl.location` + `BUILTIN.DF(tl.location) AS location_name`
158
+ to the `listSalesOrders()` line query.
159
+ 2. Merge **c3caa11a** (2026-06-24, *"Merge branch '_production' into TRUE-79078"*) resolved the
160
+ conflict in favour of `_production` and **silently dropped both columns** — leaving
161
+ `rest.php:521-523` still *reading* `$line->location`, so `$lineShim->location` became
162
+ unconditionally null. No syntax error, no test failure, just a permanently-null field.
163
+ 3. The NYCHH transfer-order work re-added them on 2026-08-27 for an unrelated reason (origin-location
164
+ resolution / `EV-10`) — see
165
+ [NYCHH TransferOrders import](../../../clients/nychh/features/netsuite-transfer-order-import.md).
166
+ A second 2026-08-27 session (forecast2 open-orders, TRUE-79162) independently re-derived the same
167
+ defect, re-verified the restored query live (returns location `4` / `New York`), and **deferred its
168
+ copy of the fix to a future TRUE-79078 PR**.
169
+
170
+ **Consequence: two same-day records disagree about whether the SELECT is present, so verify the branch
171
+ you are on** — `git grep -n "tl.location" library/app/api/netsuite/rest.php` — rather than trusting
172
+ either doc. And note what restoring it *re-animates*: the leaf→root location rollup at
173
+ `worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php:1636-1934`, which
174
+ **auto-inserts and renames `Forecast.Locations` rows**, has been dead code the whole time
175
+ `$line->location` was null. It stays dormant only because `import_open_orders.php` is `"active": 0`
176
+ — re-enable that cron on a branch that selects `tl.location` and a dimension table starts being
177
+ written by a code path nobody has exercised since June. See
178
+ [open-orders sync](../../../../2.0/apps/worker2/features/netsuite-salesorder-open-orders-sync.md).
179
+ - **A merge resolved "in favour of `_production`" is a silent feature revert — re-grep for your own
180
+ additions afterwards.** The c3caa11a case is the pattern: it removed two SELECT columns while leaving
181
+ their consumers in place. It also broke `listLocations()`'s closing brace, and *that* half **was**
182
+ repaired later (**5c1c194e**, "TRUE-80090: fixing syntax issue") — which is precisely what makes the
183
+ whole breakage read as already-fixed to the next person who looks.
149
184
  - The shim must stay PHP 7.2-compatible (library is 7.2 prod): no arrow functions, typed props,
150
185
  `??=`, or `match`. Lint with `C:\xampp7\php\php.exe -l` before deploying.
151
186
  - Field availability varies by NetSuite account **and** record type — probe the live account
@@ -157,6 +192,19 @@ None — uniform across clients (NetSuite is a single shared account).
157
192
 
158
193
  ## Change history
159
194
 
195
+ - 2026-08-27 — **Traced the missing `tl.location` SELECT to its cause — a merge, not an omission — and
196
+ recorded what restoring it re-animates** (forecast2 open-orders, TRUE-79162). TRUE-79078 (31fadd77)
197
+ originally added `tl.location` + `BUILTIN.DF(tl.location)`; merge c3caa11a (2026-06-24) resolved in
198
+ favour of `_production` and dropped both while `rest.php:521-523` kept reading `$line->location`, so
199
+ the field was unconditionally null — and the leaf→root rollup at
200
+ `common_import_sales_from_netsuite.php:1636-1934`, which **auto-inserts/renames `Forecast.Locations`
201
+ rows**, has been dead code since (dormant only because `import_open_orders.php` is `active: 0`). Same
202
+ merge broke `listLocations()`'s brace and *that* half was repaired separately (5c1c194e), which is why
203
+ the breakage looks already-fixed. This session's own fix was verified live (location `4` /
204
+ `New York`) but **deferred to a future TRUE-79078 PR**, so it disagrees with the NYCHH entry below
205
+ about the current state — grep the branch before assuming. Also re-confirmed `tl.id` vs
206
+ `linesequencenumber` at scale: they diverge on 2,061 of 4,387 lines, and
207
+ `OpenOrderItems.lineNumber` == `tl.id` on 99/99 sampled prod rows. (kyalamarthi)
160
208
  - 2026-08-27 — Recorded three field-semantics facts from NYCHH transfer-order support:
161
209
  `listSalesOrders()` line SuiteQL now **selects `tl.location` + `BUILTIN.DF(tl.location)`** (the line
162
210
  shim read them but never selected them → always null, breaking TO origin resolution / V2 `EV-10`);
@@ -7,7 +7,7 @@
7
7
  | [Compass Manager Approval Reminder Emails (1.0 worker crons)](features/compass-manager-approval-reminder-emails.md) | Two 1.0 worker crons nag approvers about sales orders still waiting on a decision — one per Compass tenant. | worker/crons/toga2/compass/compass_email_reminders.php, worker/crons/toga2/compasscanada/compass_email_reminders.php, worker/schedules/cron.worker.sync.json, worker1.5/crons/toga2/compass/compass_email_reminders.php, worker1.5/schedules/cron.worker.json |
8
8
  | [Compass Partial In-Transit & Delivered Emails (per package)](features/compass-partial-in-transit-delivered-emails.md) | Compass USA and Compass Canada send a **per-package** in-transit email (and a matching delivered email) instead of one email listing the whole order. | worker/crons/toga2/compass/update_salesorder_status_from_odp.php, worker/crons/toga2/compass/send_delivered_email.php, worker/crons/toga2/compasscanada/update_salesorder_status_from_odp.php, worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php, worker/crons/toga2/compasscanada/send_delivered_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_delivered_email.php, library/app/client/compasscanada.php |
9
9
  | [Elite TOGA 2.0 → TOGaDeskSupport Standalone Attachment Sync](features/elite-togadesk-attachment-sync.md) | `sync_togadesk_elite_attachments.php` is a standalone cron (every 5 minutes) that syncs file attachments from TOGA 2.0 into TOGaDeskSupport for Elite. | worker/crons/toga2/elite/sync_togadesk_elite_attachments.php, worker/crons/toga2/elite/test_sync_togadesk_elite_attachments.php |
10
- | [Forecast2 ↔ NetSuite Reconciliation & Trueup Tooling](features/forecast2-netsuite-reconciliation.md) | CLI tools to **audit** and **repair** drift between the production `Forecast` DB (core2) and NetSuite. | test/@dave/checker.php, worker2/Component/Forecast/SaleImport/SaleImport.php, test/@dave/looper.php, test/@dave/reconcile_netsuite_totals.php, test/@dave/fixer.php, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.php, test/@dave/reconcile_drift_2023plus.php, test/@dave/probe_invoice_gap_2026.php, test/@dave/probe_creditmemo_gap_detail.php, test/@dave/trueup_open_orders.php, test/@dave/loop_trueup_open_orders.php, test/@dave/trueup_opportunities.php, test/@dave/probe_sales_gap_direct.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_profit_invoices.php, test/@dave/probe_profit_gap.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_open_orders.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/schedules/cron.worker.infrastructure.json |
10
+ | [Forecast2 ↔ NetSuite Reconciliation & Trueup Tooling](features/forecast2-netsuite-reconciliation.md) | CLI tools to **audit** and **repair** drift between the production `Forecast` DB (core2) and NetSuite. | test/@dave/checker.php, worker2/Component/Forecast/SaleImport/SaleImport.php, test/@dave/looper.php, test/@dave/reconcile_netsuite_totals.php, test/@dave/fixer.php, tools/bin/forecast/fixer.php, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.php, test/@dave/reconcile_drift_2023plus.php, test/@dave/probe_invoice_gap_2026.php, test/@dave/probe_creditmemo_gap_detail.php, test/@dave/trueup_open_orders.php, test/@dave/loop_trueup_open_orders.php, test/@dave/trueup_opportunities.php, test/@dave/probe_sales_gap_direct.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_profit_invoices.php, test/@dave/probe_profit_gap.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_open_orders.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/schedules/cron.worker.infrastructure.json |
11
11
  | [NetSuite Sales Order Sales Rep Sourcing (Staples & ODP EDI orders)](features/netsuite-sales-order-sales-rep-sourcing.md) | How the **sales rep** on a NetSuite Sales Order is determined for the two 1.0 `worker` EDI order-creation integrations (Staples cXML and Compass/ODP EDI). | worker/crons/toga2/compass/workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php, worker/crons/sync/staples/sync_staples_cxml.php, test/@Mark/NetSuite/TRUE_80451_customer_salesrep_diag.php |
12
12
  | [NetSuite → TOGa Supply Per-Client Sync (thin wrappers)](features/netsuite-togasupply-per-client-sync.md) | Syncs NetSuite transactions (sales orders, purchase orders, invoices, item receipts, item fulfillments, inventory adjustments) into each TOGa Supply (2.0) clien | worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/sync_togasupply_canon.php, worker/crons/toga2/netsuite/sync_togasupply_elite.php, worker/schedules/cron.worker.sync.json, dbchanges2/_modules/netsuite/2026-04-01 - Parameters.sql, library/app/api/toga2.php, library/app/api/netsuite/rest.php, library/app/framework.php, library/app/systemmonitor/netsuiteintegration.php, test/@srija/Elite Testing/Service Requests/test_sync_togasupply_elite_section.php, test/@srija/Elite Testing/Service Requests/test_diagnose_togasupply_elite.php |
13
13
  | [OneUptime Server monitor + disk/memory hygiene on the 1.0 worker EB host](features/oneuptime-server-monitor-host-hygiene.md) | The 1.0 `agilant-worker` EB environment runs on the **legacy Amazon Linux 1 PHP 7.2 platform** (Apache httpd/prefork, s3fs mounts, cron) and repeatedly went dow | worker/.ebextensions/040_disk_memory_hygiene.config, worker/.ebextensions/045_oneuptime_agent.config, worker/ebs/cron.worker.php, worker/ebs/mount-s3fs-folders.php, worker/ebs/apache_settings.php, worker/ebs/setup_phpini.php |
@@ -6,14 +6,15 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-23
10
- owners: [dfranks, jcardinal]
9
+ updated: 2026-08-27
10
+ owners: [dfranks, jcardinal, kyalamarthi]
11
11
  files:
12
12
  - test/@dave/checker.php
13
13
  - worker2/Component/Forecast/SaleImport/SaleImport.php
14
14
  - test/@dave/looper.php
15
15
  - test/@dave/reconcile_netsuite_totals.php
16
16
  - test/@dave/fixer.php
17
+ - tools/bin/forecast/fixer.php
17
18
  - test/@dave/analyze_netsuite_forecast_diff.php
18
19
  - test/@dave/trueup_sales.php
19
20
  - test/@dave/reconcile_drift_2023plus.php
@@ -333,6 +334,44 @@ by reconciling a chosen tranDate range directly against NetSuite.
333
334
  is the load-bearing reason the reconciliation backstop exists, not an optimization. (Oracle docs:
334
335
  server scripting on CSV import `section_4676525683`; how UE scripts are executed `section_1512409310`.)
335
336
 
337
+ ### The open-orders discrepancy-fix cron now carries `locationId` (2026-08-27)
338
+
339
+ `periodic_forecast_discrepancy_fix_open_orders.php` (`active:1`, `0 3 * * *`) contained **zero**
340
+ occurrences of the string "location" in 775 lines: its line SuiteQL didn't select it,
341
+ `buildInsertSql()` omitted `locationId` from both the SET list **and** the `ON DUPLICATE KEY UPDATE`
342
+ list, `buildUpdateSql()` omitted it, and the `$compare` array had no `locationId` key. Because the cron
343
+ **delete+reinserts** rows (three delete sites), every row it recreated came back with `locationId` NULL
344
+ — which is why the 2.0 webhook writer alone could never hold the value. Root cause and census live in
345
+ [NetSuite → Forecast Open-Orders Sync](../../../../2.0/apps/worker2/features/netsuite-salesorder-open-orders-sync.md).
346
+
347
+ Five edits, in the order they matter:
348
+
349
+ 1. `location AS locationid` added to the line SuiteQL.
350
+ 2. A `Locations` lookup plus `$hasLocationCol` / `netsuiteParentLocationId` **column guards** built on
351
+ the file's existing `forecastColumnExists()` helper — dev-sandbox has no `locationId` column at all,
352
+ so an unguarded write raises **MySQL 1054** there.
353
+ 3. `resolveOoiLocationId()` copied from **`tools/bin/forecast/fixer.php:1328-1342`** (which already
354
+ mirrors `_Component_Forecast_Db::resolveLocationId`) rather than lifting the entangled tree walk out
355
+ of `common_import_sales_from_netsuite.php`. Pick the copy that is already a faithful mirror — that is
356
+ the cheapest way to honour the compute-identically invariant.
357
+ 4. `locationId` threaded through **both** SQL builders, which were also converted from **12 positional
358
+ parameters to a single array parameter** per the team 4+-parameter rule.
359
+ 5. A **null-safe `locationId` entry added to `$compare`.**
360
+
361
+ **Edit 5 is the one that turns the cron into its own backfill — and it is the generalizable lesson.**
362
+ Without a `$compare` entry, a row whose *only* difference from NetSuite is a missing `locationId` is
363
+ classified **"unchanged"** and is never repaired, so the fix would have covered new rows and nothing
364
+ else. With it, the cron repairs every row inside its `[today−2y, today+10y]` `tranDate` window (1,711
365
+ rows / 750 orders) on its next run, and only the 1,133 rows / 608 orders that fall **before** the window
366
+ (back to 2019-04-24, scanned by nothing at all) need a separate backfill — that is
367
+ `Netsuite/OpenOrderLocationBackfill/Backfill` in worker2.
368
+
369
+ **Verification gotcha worth keeping:** the first cut of the `ON DUPLICATE KEY UPDATE` clause rendered
370
+ `classificationId = VALUES(classificationId),,` plus a missing separator before `itemId` — which would
371
+ have broken **every insert the cron makes**. It was caught only by rendering the generated SQL through a
372
+ stub harness before any run. **`php -l` cannot catch malformed SQL string assembly**; when you edit a
373
+ query *builder*, print the built query and read it.
374
+
336
375
  ## Data model
337
376
 
338
377
  `Forecast.Sales`, `Forecast.OpenOrderItems` on the **core2** cluster
@@ -558,7 +597,12 @@ None — Forecast2 is a single shared dataset.
558
597
  **not** a resurrection risk against the webhook `removeAll` path; it is in fact the current backstop
559
598
  deleter for orders the webhook never sees billed (the invoice-transform gap).
560
599
  - **`fixer.php`'s OpenOrderItems path does NOT carry `locationId` / `quantityBackordered` /
561
- `amountDue` — mirror any new open-order column here or the reconciler reverts it.** `fixer.php`
600
+ `amountDue` — mirror any new open-order column here or the reconciler reverts it.** (Still true as of
601
+ 2026-08-27 for the write path, even though `tools/bin/forecast/fixer.php:1328-1342` already ships a
602
+ `resolveOoiLocationId()` helper mirroring `_Component_Forecast_Db::resolveLocationId` — a helper
603
+ existing is not the same as the OOI SELECT/compare/INSERT/UPDATE using it. The nightly cron **was**
604
+ fixed for `locationId` (above); `fixer.php` was **not**, so a `fixer`/`looper` pass can still revert
605
+ it.) `fixer.php`
562
606
  already handles anchor-line **`amountDue` on the Sales path**, guarded by a column-existence check
563
607
  `$hasAmountDueCol = forecastColumnExists('Sales','amountDue')` (so it's a no-op until the prod
564
608
  column lands). Its **OOI** path — the lookup SELECT, change-detection, the
@@ -602,6 +646,24 @@ None — Forecast2 is a single shared dataset.
602
646
 
603
647
  ## Change history
604
648
 
649
+ - 2026-08-27 — **Taught the nightly open-orders discrepancy-fix cron about `locationId` — and turned it
650
+ into its own backfill (TRUE-79162).** The cron had **zero** occurrences of "location" in 775 lines
651
+ (line SuiteQL, both SQL builders, and `$compare` all omitted it) and it delete+reinserts rows, so it
652
+ was actively erasing the value the 2.0 webhook writer had set. Five edits: `location AS locationid` in
653
+ the line query; a `Locations` lookup behind `forecastColumnExists()` column guards (dev-sandbox has no
654
+ `locationId` column — unguarded writes raise MySQL 1054); `resolveOoiLocationId()` copied from
655
+ `tools/bin/forecast/fixer.php:1328-1342` (already a faithful mirror of
656
+ `_Component_Forecast_Db::resolveLocationId`) instead of the entangled walk in
657
+ `common_import_sales_from_netsuite.php`; `locationId` threaded through both SQL builders, which were
658
+ refactored from **12 positional params to one array param** per the 4+-param rule; and a null-safe
659
+ `locationId` entry in **`$compare`** — which is what makes the cron repair the 1,711 in-window rows
660
+ automatically (without it, a row whose only difference is a missing location classifies "unchanged"
661
+ and is never touched). The 1,133 pre-window rows go to worker2's
662
+ `Netsuite/OpenOrderLocationBackfill/Backfill`. Recorded the verification gotcha: the first cut of the
663
+ `ON DUPLICATE KEY UPDATE` clause rendered a double comma and a missing separator that would have broken
664
+ **every** insert — caught by rendering the generated SQL through a stub harness, which `php -l`
665
+ cannot do. Also corrected the `fixer.php`-OOI gotcha: `fixer.php` still does not write `locationId`
666
+ even though it already ships the `resolveOoiLocationId()` helper. (kyalamarthi)
605
667
  - 2026-07-23 — **Recorded WHY the reconciliation backstop is mandatory + the by-id re-fetch
606
668
  delete-safety mechanism** (TRUE-80262 planning; no code shipped, dfranks). Documented the broader
607
669
  NetSuite event-capture blind spot beyond the inline sublist edit: **bulk/mass updates and CSV
@@ -4,7 +4,7 @@
4
4
  |-----|---------|-------|
5
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 |
6
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 |
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, _underscore/Model/Client/TrackingNumber.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql, dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql |
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, _underscore/Model/Core/Surface.php, _underscore/Model/Client/TrackingNumber.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql, dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql |
8
8
  | [Address Uniqueness Normalization (unit identifier + 5-digit ZIP comparison)](features/address-uniqueness-normalization.md) | When a business rule says *"only one X per physical address"*, comparing address rows field-for-field does **not** work: the same dwelling is spelled many diffe | _underscore/Model/Rate/Entitlement.php |
9
9
  | [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, _underscore/Component/Library/Carriers/Usps/Usps.php |
10
10
  | [_ApiRequest — JSON encode/decode & api-logging behavior](features/apirequest-json-content-type.md) | `_ApiRequest` is the 2.0 outbound HTTP client. | _underscore/ApiRequest.php |
@@ -30,7 +30,7 @@
30
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 |
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
- | [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/Trait/Netsuite/SalesOrder.php, worker2/Worker/Netsuite/SalesOrder.php, worker2/Component/Forecast/SaleImport/SaleImport.php, worker2/Worker/Netsuite/Opportunity.php |
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 |
34
34
  | [NetSuite Sales Order sync — ship-to address, phone, and PO reference sourcing](features/netsuite-salesorder-address-phone-sync.md) | `_Trait_Netsuite_SalesOrder` is the **shared** sales-order importer composed into **22 client models** (every client on the dbchanges2 `netsuite` module). | _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Model.php, dbchanges2/_modules/netsuite/2026-08-10a - AddressPhoneNumberApiRoleAcl.sql |
35
35
  | [Legacy page meta (Page::meta) & context-scoped ClientRecordFieldSettings](features/page-meta-context-field-settings.md) | `_Model_Core_Page::meta()` is the **legacy** page-meta resolver behind `GET /pages/meta?slug=<slug>` — still the live path for `toga2-supply` and other pre-Surf | _underscore/Model/Core/Page.php, _underscore/Model/Client/TableView.php, toga2-supply/src/components/ui/Tables/PrimaryTable/PrimaryTable.tsx, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/formatTableData.tsx |
36
36
  | [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/Model.php, _underscore/Query.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php, api2/Controller/Index.php |
@@ -41,7 +41,7 @@
41
41
  | [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 |
42
42
  | [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 |
43
43
  | [_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 |
44
- | [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 |
44
+ | [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 |
45
45
  | [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 |
46
46
  | [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, dbchanges2/Client/2026-08-11a - ServiceRequestsInventoryUnitsTableViews.sql, dbchanges2/Client_Elite/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql |
47
47
  | [TogaIQ Gateway Client (_Component_Api_Togaiq) — AI generate/translate from 2.0](features/togaiq-gateway-client.md) | `_Component_Api_Togaiq` is the 2.0 framework's client for the **TogaIQ** (Talos) AI gateway. | _underscore/Component/Api/Togaiq/Togaiq.php, _underscore/ApiRequest.php |
@@ -6,11 +6,12 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-26
9
+ updated: 2026-08-28
10
10
  owners: ["jcardinal", "mhammontree", "tcox", "bala"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - _underscore/Model/Core/Page.php
14
+ - _underscore/Model/Core/Surface.php
14
15
  - _underscore/Model/Client/TrackingNumber.php
15
16
  - dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql
16
17
  - dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql
@@ -273,6 +274,31 @@ while the button that would invoke them is hidden, so every permission check you
273
274
  `AclActionPermissions` against a client where the control works, and grant the missing
274
275
  `(aclActionId, roleId)` in a `dbchanges2` migration.
275
276
 
277
+ ### ⚠ This gate applies ONLY to `Page::meta` apps — it is DEAD CONFIG in the surface-driven 2.5 app
278
+
279
+ The flag mechanism above is `_Model_Core_Page::meta()` behavior, and `Page::meta` is being **replaced**
280
+ by the Surface layer. In a surface-driven app (`toga25-supply`) the same-named `navigation-*` flags
281
+ are **inert**:
282
+
283
+ - `Core.AclActions` carries `navigation-approvals` (1), `navigation-edit-on-commerce` (2),
284
+ `navigation-sales-orders` (3), `navigation-items` (4), `navigation-vendor-items` (5),
285
+ `navigation-service-requests` (6), `navigation-edit-on-vendor-items` (7), and every tenant grants
286
+ some subset of them per role in `Client_<tenant>.AclActionPermissions`. It **looks** exactly like
287
+ the nav permission gate.
288
+ - But `_Model_Core_Surface::resolve()` only drops an element when **the element declares** an
289
+ `aclActionId`:
290
+ `if ($element->aclActionId && !isset($permittedAclActionIds[(int)$element->aclActionId])) continue;`
291
+ (Surface.php ~L592). Verified prod-wide 2026-08-28: **no `SurfaceElement` on `appId = 1` sets
292
+ `aclActionId` at all** — all six `navigation` elements have it `NULL`.
293
+
294
+ **So granting a role `navigation-sales-orders` has ZERO effect on the 2.5 side nav.** The only lever
295
+ is a role-scoped `Client_<tenant>.SurfaceOverrides` row with `attribute = 'IS_VISIBLE'`, `value = '1'`.
296
+ Both facts are true at once: the *same* `AclActionPermissions` row can be load-bearing in
297
+ `toga2-supply` and completely inert in `toga25-supply`. Before you diff `AclActionPermissions`
298
+ across clients, establish **which app** the missing control is in. Detail:
299
+ [Surface Resolver](surface-resolver.md) and
300
+ [toga25-supply side navigation](../../toga25-supply/features/side-navigation-and-default-route.md).
301
+
276
302
  Worked example (Quad, 2026-08-26): the vendor-items **Add and Edit buttons both** read
277
303
  `acl['vendor-items']['navigation-edit-on-vendor-items']` (AclActions id 7). `Client_Quad` had a row
278
304
  for id 5 only, so the page was reachable but button-less, even though record 19 already granted role
@@ -303,6 +329,21 @@ Worked example: the reprint API `GET /v2/tracking-numbers/reprint`
303
329
  Client-DB `AclRecordScripts` grant (migrations in `dbchanges2`) before it stopped 403ing — see the
304
330
  carrier-shipping-labels feature doc.
305
331
 
332
+ **⚠ `AclRecordScripts` lives in the client DB, but its `roleId` is NOT always a client role id.**
333
+ The role space is chosen by the **record**, exactly as for the CRUD chain above:
334
+ `V2.php` (~L3090) reads
335
+ `if ($record->aclDatabase == \_Model_Core_Record::ACL_DATABASE_CORE) { $roleIds = jwt id.core.roles; } else { $roleIds = jwt id.client.roles; }`
336
+ and `getRecordScriptPhpMethod()` (~L6198) then runs
337
+ `SELECT id FROM AclRecordScripts WHERE recordScriptId = <n> AND roleId IN (<$roleIds>)` against the
338
+ **client** DB with whichever set it got. So for a `CORE` record such as `surfaces`, the client-DB
339
+ `AclRecordScripts.roleId` values must be **`Core.Roles`** ids.
340
+
341
+ api2 derives `id.core.roles` at token time (~L1611-1627) as
342
+ `SELECT DISTINCT coreRoleId FROM Client_<tenant>.Roles WHERE id IN (<client roles>) AND coreRoleId IS NOT NULL`.
343
+ Most tenant roles have `coreRoleId = NULL`, so this set is usually just `1` (Base) — which is why a
344
+ single `AclRecordScripts` row for core role 1 lets an entire tenant through, and why this gate is
345
+ seldom the cause of an "empty screen" report. Verified prod 2026-08-28 against `Client_Compass`.
346
+
306
347
  ## Add a writable field to a V2 record (FOUR requirements)
307
348
 
308
349
  Making a field persisted **and** API-writable on an existing V2 record takes **four** things —
@@ -436,6 +477,17 @@ hardcoded `Core.RecordFields` id literals instead of a subselect.
436
477
  and every repo is on the **same branch** so the generated model matches the DB.
437
478
 
438
479
  ## Change history
480
+ - **2026-08-28** — Scoped the **third gate**: `Core.AclActions` + `AclActionPermissions` is
481
+ `_Model_Core_Page::meta()` behavior only, and is **dead config in the surface-driven `toga25-supply`
482
+ app** — `_Model_Core_Surface::resolve()` drops an element only when the element itself declares an
483
+ `aclActionId`, and no `SurfaceElement` on `appId = 1` does (verified prod-wide). Granting
484
+ `navigation-sales-orders` therefore does nothing for the 2.5 side nav; only a role-scoped
485
+ `SurfaceOverrides IS_VISIBLE` row works. The same `AclActionPermissions` row can be load-bearing in
486
+ `toga2-supply` and inert in `toga25-supply`, so establish the app before diffing grants. Also
487
+ corrected the `AclRecordScripts` gate: the table is client-DB but its `roleId` is matched against
488
+ the space `Records.aclDatabase` selects — **`Core.Roles`** ids for a `CORE` record such as
489
+ `surfaces` (`V2.php` ~L3090 / ~L6198), with `id.core.roles` derived from
490
+ `Roles.coreRoleId` (~L1611-1627) and therefore usually just Base. (bala)
439
491
  - 2026-08-27 — Recorded two facts from wiring NYCHH transfer-order custom fields: **there is NO
440
492
  ACL/metadata cache** (`buildLookups()` re-reads per request, so a new grant/metadata row applies on
441
493
  the next request — no cache-bust exists; a whole debugging pass was wasted assuming one did), and a
@@ -6,10 +6,12 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-25
10
- owners: ["dfranks", "jcardinal", "bala"]
9
+ updated: 2026-08-27
10
+ owners: ["dfranks", "jcardinal", "bala", "kyalamarthi"]
11
11
  files:
12
12
  - _underscore/Component/Api/Netsuite/Netsuite.php
13
+ - _underscore/ApiRequest.php
14
+ - _underscore/Model/Client/Logs/Api.php
13
15
  - _underscore/Trait/Netsuite/SalesOrder.php
14
16
  - worker2/Worker/Netsuite/SalesOrder.php
15
17
  - worker2/Component/Forecast/SaleImport/SaleImport.php
@@ -208,10 +210,30 @@ consumer sources transaction status from a change-safe field:
208
210
 
209
211
  ## Logging
210
212
 
211
- `send()` calls `setLogging(false)`, so **NetSuite REST request/response bodies are NOT written to
212
- `Logs.Api`** — unlike platform/ClickUp/Freshservice/Sentry traffic. You cannot pull a historical
213
- NetSuite response for debugging. (Removing the default-off is under review; see the opportunity-sync
214
- doc.)
213
+ > **CORRECTED 2026-08-27 — the opposite of what this section used to say.** It claimed
214
+ > `send()` calls `setLogging(false)` and that NetSuite bodies are *not* logged. Verified false:
215
+ > `_underscore/Component/Api/Netsuite/Netsuite.php` contains **zero** occurrences of `setLogging`, and
216
+ > all three `_ApiRequest` constructions in it (`:100` authenticate, `:138` send, `:178`) pass only **4**
217
+ > arguments — so the constructor's `$isLoggingEnabled = true` default applies.
218
+
219
+ **Every** call through `_Component_Api_Netsuite` is logged — including a read-only SuiteQL `SELECT`.
220
+ `_ApiRequest::execute()` writes a row to `DB_CLIENT_LOGS` via `_Model_Client_Logs_Api`
221
+ (`_underscore/ApiRequest.php:253-270`) on each request.
222
+
223
+ Two consequences, one good and one expensive:
224
+
225
+ - **You *can* pull a historical NetSuite request/response for debugging** — the rows are in the
226
+ client-logs cluster, not missing. (Any older doc or comment telling you the bodies were never
227
+ captured is wrong; the payload-volume concern is real, but it is a retention/sampling question, not a
228
+ "logging is off" fact.)
229
+ - **A machine with no `ClientLogs` schema cannot make even a read-only NetSuite query**, and the failure
230
+ presents as a **database credentials/unknown-database error rather than a logging error** — which is
231
+ why it reads as a NetSuite auth problem. See
232
+ [running worker2 locally](../../worker2/workflows/running-worker2-locally.md) and
233
+ [per-client database connections](./per-client-database-connections.md).
234
+
235
+ Note that `Authorization` headers are persisted in plaintext by that same auto-logging path — see
236
+ [`_ApiRequest` JSON/logging behavior](./apirequest-json-content-type.md).
215
237
 
216
238
  ## Gotchas / known issues
217
239
 
@@ -231,6 +253,15 @@ doc.)
231
253
 
232
254
  ## Change history
233
255
 
256
+ - 2026-08-27 — **CORRECTED the Logging section: NetSuite REST calls ARE logged to `Logs.Api`, always.**
257
+ The previous claim (`send()` calls `setLogging(false)`, bodies not captured) is false — there are
258
+ **zero** `setLogging` occurrences in `_underscore/Component/Api/Netsuite/Netsuite.php`, and all three
259
+ `_ApiRequest` constructions (`:100`, `:138`, `:178`) pass only 4 args, so the constructor's
260
+ `$isLoggingEnabled = true` default applies. Every call — including a read-only SuiteQL SELECT —
261
+ writes a `_Model_Client_Logs_Api` row to `DB_CLIENT_LOGS` (`ApiRequest.php:253-270`). Durable
262
+ consequence: a laptop with no `ClientLogs` schema **cannot run even a read-only NetSuite query**, and
263
+ the failure surfaces as a database-credentials error, not a logging error. Surfaced while building the
264
+ open-orders location backfill (TRUE-79162). (kyalamarthi)
234
265
  - 2026-08-25 — Documented **2.0 NetSuite transaction-status sourcing** and the conclusion that
235
266
  the **NetSuite 2026.2 REST `status.id` text→letter standardization does not affect 2.0**
236
267
  (SuiteAnswers 89313). `fetchRecord()` returns the REST record raw (no central status
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-27
10
- owners: [jcardinal, apeterson]
9
+ updated: 2026-08-28
10
+ owners: [jcardinal, apeterson, bala]
11
11
  files:
12
12
  - _underscore/Model/Client/Language.php
13
13
  - dbchanges2/Core/2026-08-21 - SalesOrderDecisionSurfacesReseed.sql
@@ -54,11 +54,15 @@ files:
54
54
  - _underscore/Model/Client/ThemeToken.php
55
55
  - _underscore/Model/Core/Page.php
56
56
  - api2/Component/Api/V2/V2.php
57
+ - dbchanges2/Core/2026-08-12a - NavigationSurfaceSeed.sql
58
+ - dbchanges2/Client_Compass/2026-08-12a - NavigationRoleGrants.sql
59
+ - toga25-supply/surface-layer-guide.md
57
60
  related:
58
61
  - ../../dbchanges2/features/surface-layer-schema.md
59
62
  - ../../api2/features/surface-meta-option.md
60
63
  - acl-permission-chain.md
61
64
  - sales-order-status-filter-surface.md
65
+ - ../../toga25-supply/features/side-navigation-and-default-route.md
62
66
  ---
63
67
 
64
68
  ## What it is
@@ -94,8 +98,11 @@ Inputs come from `$api` (the same accessor `_Model_Client_TableView::meta` uses)
94
98
  injection).
95
99
  2. One query: all `SurfaceElements` for `surfaceId`, ordered by `sortOrder`.
96
100
  3. One query: matching `SurfaceOverrides`; apply **most-specific-last** in a single in-memory pass
97
- (`ORDER BY (roleId IS NULL) DESC, (personaId IS NULL) DESC`; language filtered to active or NULL).
98
- Precedence **base < client < persona < role**, language an orthogonal overlay.
101
+ (`ORDER BY (roleId IS NULL) DESC, (personaId IS NULL) DESC, id ASC`; language filtered to active
102
+ or NULL). Rows are written into a `map[elementId][attribute]` as they are read, so **last write
103
+ wins** and the ORDER BY *is* the precedence rule: **base < client-wide < persona < role**, with
104
+ `id ASC` breaking ties inside a tier (later-inserted row wins). Language is an orthogonal
105
+ overlay. `_loadOverrides` (Surface.php ~L740).
99
106
  4. **ACL composition, not absorption** — batch-resolve permissions via
100
107
  `_Model_Client_AclActionPermission`; drop elements whose `aclActionId` isn't permitted (ACL stays
101
108
  exactly as-is, see [acl-permission-chain](acl-permission-chain.md)).
@@ -446,23 +453,90 @@ first layer wrong and you get a **403 `EZ-1 AUTHORIZATION`** before any element
446
453
  1. **Route-level dispatch gate — `Client_<tenant>.AclRecordScripts`.** The surfaces dispatcher
447
454
  authorizes each API script (route + method + phpMethod) via the **CLIENT-tier**
448
455
  `AclRecordScripts` table (cols `uuid`, `recordScriptId` → `Core.RecordScripts`, `roleId` →
449
- `Client_<tenant>.Roles`). The check is keyed on the JWT's **`id.client.roles`** (client roles),
450
- **NOT** the platform `core.roles` and **NOT** the Core `AclRecordPermissions` chain.
456
+ `Client_<tenant>.Roles`) — **NOT** the Core `AclRecordPermissions` chain.
451
457
  `Core.RecordScripts` maps route/method/phpMethod → an ACL "record" (`recordId`). The three
452
458
  surfaces scripts: `meta` (GET, phpMethod `meta`), `meta-group` (GET, `metaGroup`), `debug` (GET,
453
- `metaDebug`). `_Model_Core_Surface::resolve/metaGroup` read the caller's roles from
454
- **`id.client.roles`** (Surface.php ~lines 56/87).
459
+ `metaDebug`).
460
+ **⚠ Which role-id space that `roleId` is matched against is decided by the RECORD, not by the
461
+ table it lives in** — see the correction below. `surfaces` is an `aclDatabase = CORE` record, so
462
+ the dispatch gate matches `AclRecordScripts.roleId` against the JWT's **`id.core.roles`**.
463
+ (Earlier revisions of this doc said `id.client.roles` here. That is right for a
464
+ `aclDatabase = CLIENT` record and **wrong** for `surfaces`.) Separately,
465
+ `_Model_Core_Surface::resolve/metaGroup` read the caller's roles from **`id.client.roles`**
466
+ (Surface.php:53 and :108) — so the two layers genuinely disagree on purpose.
455
467
  2. **Element-level ACL (runs *inside* `meta`/`metaGroup`, AFTER the route gate passes)** — dropping
456
468
  individual surface elements uses a **separate** client-tier table `_Model_Client_AclActionPermission`,
457
469
  gated by each `SurfaceElement.aclActionId`. This is the "composition, not absorption" step in
458
470
  `resolve()` step 4; it never causes the route-level 403.
459
471
 
460
472
  **Debugging a surfaces 403 EZ-1 (hard-won):** it is the route-level gate — a missing
461
- `Client_<tenant>.AclRecordScripts` grant resolved by **client role**. Do **not** chase
473
+ `Client_<tenant>.AclRecordScripts` grant. Do **not** chase
462
474
  `Core.AclRecordPermissions`, `appId` scoping, or `AclLogicGroups`/`AclRecordExpressions` — all were
463
475
  investigated and ruled out as red herrings: `appId = NULL` is the normal working pattern, and the
464
476
  Core record grants + their logic-group expressions all evaluate `all`/`"1"`. The gate is purely
465
- "does this client role have an `AclRecordScripts` row for this `recordScriptId`?"
477
+ "does one of the caller's roles have an `AclRecordScripts` row for this `recordScriptId`?" —
478
+ and for `surfaces` those are **CORE** role ids (corrected 2026-08-28; earlier wording said client
479
+ role, see below).
480
+
481
+ ### ⚠ The two gates read DIFFERENT role-id spaces — the classic time-sink (verified prod 2026-08-28)
482
+
483
+ The single fact that makes a surfaces authorization question tractable: **`AclRecordScripts.roleId`
484
+ and `SurfaceOverrides.roleId` are ids from two different tables, even though both rows sit in
485
+ `Client_<tenant>`.**
486
+
487
+ | Gate | Table (all in `Client_<tenant>`) | `roleId` means | JWT source |
488
+ |---|---|---|---|
489
+ | Route dispatch (403 or not) | `AclRecordScripts` | a **`Core.Roles`** id, because `surfaces.aclDatabase = CORE` | `id.core.roles` |
490
+ | Element visibility cascade | `SurfaceOverrides` | a **`Client_<tenant>.Roles`** id | `id.client.roles` |
491
+
492
+ Mechanism, verified in source:
493
+
494
+ - `V2.php` (~L3090) picks the role space off the record:
495
+ `if ($record->aclDatabase == \_Model_Core_Record::ACL_DATABASE_CORE) { $roleIds = ...['id','core','roles']; } else { ...['id','client','roles']; }`
496
+ and hands that `$roleIds` straight to `getRecordScriptPhpMethod()` (~L6198), which does
497
+ `SELECT id FROM AclRecordScripts WHERE recordScriptId = <n> AND roleId IN (<$roleIds>)`.
498
+ - api2 derives `id.core.roles` at token time (~L1611-1627):
499
+ `SELECT DISTINCT coreRoleId FROM Client_<tenant>.Roles WHERE id IN (<client roles>) AND coreRoleId IS NOT NULL`.
500
+ Most tenant roles have `coreRoleId = NULL`, so the **core** role set is tiny — typically just
501
+ `1` (Base), inherited from whichever client role maps to it.
502
+ - `_Model_Core_Surface::resolve` / `metaDebug` read `['id','client','roles']` (Surface.php:53, :108),
503
+ so the cascade never sees a core role id.
504
+
505
+ **Practical consequence:** the dispatch gate is almost never the blocker for a normal user. Any user
506
+ holding a client role that maps to `coreRoleId = 1` passes `meta`/`meta-group`, so an
507
+ `AclRecordScripts` row for core role 1 covers the whole tenant. Worked example — `Client_Compass`
508
+ has `AclRecordScripts` `(28,1) (28,3) (28,4) (29,3) (30,1) (30,3) (30,4)` and only roles 1 (Base)
509
+ and 10 (Public) carry `coreRoleId = 1`, yet **every** Compass user passed the gate, because every
510
+ Compass user holds Base. When a screen comes back empty, go to the `SurfaceOverrides` cascade
511
+ (**client** role ids) — not to `AclRecordScripts`.
512
+
513
+ ### Prod ids for the `surfaces` record and its scripts — the toga25-supply guide lists DEV-SANDBOX ids
514
+
515
+ `toga25-supply/surface-layer-guide.md` documents `Core.Records` **2300** and `Core.RecordScripts`
516
+ **25 / 26 / 27**. Those are **dev-sandbox** values. **Do not reuse them in a prod migration or a
517
+ prod query.** Verified against prod Core on 2026-08-28:
518
+
519
+ | Object | Prod id | Detail |
520
+ |---|---|---|
521
+ | `Core.Records` `surfaces` | **333** | model `\_Model_Core_Surface`, route `surfaces`, `aclDatabase = CORE` |
522
+ | `Core.RecordScripts` `meta` | **28** | GET → phpMethod `resolve` |
523
+ | `Core.RecordScripts` `debug` | **29** | GET → phpMethod `metaDebug` |
524
+ | `Core.RecordScripts` `meta-group` | **30** | GET → phpMethod `metaGroup` |
525
+
526
+ Same discipline as the reserved-id blocks: **read the id out of the target environment before you
527
+ write the file**; a surface-layer id that is correct on dev-sandbox is a silent mis-target on prod.
528
+
529
+ ### The element `aclActionId` gate is dead across the WHOLE app, not just navigation
530
+
531
+ `resolve()` step 4 only ever drops an element that **declares** an `aclActionId`
532
+ (`if ($element->aclActionId && !isset($permittedAclActionIds[(int)$element->aclActionId])) continue;`
533
+ — Surface.php ~L592, mirrored in `metaDebug`'s `aclDropped` at ~L169). Verified prod-wide on
534
+ 2026-08-28: **no `SurfaceElement` on `appId = 1` sets `aclActionId` at all.** So the
535
+ `AclActionPermissions` layer is currently inert for every surface in the 2.5 app — the nav surface
536
+ is just the case people trip over. Element visibility in practice = the `SurfaceOverrides` cascade,
537
+ full stop. See
538
+ [acl-permission-chain → Navigation / action flags](acl-permission-chain.md) for why the
539
+ `navigation-*` `AclActions` rows still exist and still matter to the *older* `Page::meta` apps.
466
540
 
467
541
  ## Gotchas
468
542
 
@@ -624,6 +698,22 @@ Core record grants + their logic-group expressions all evaluate `all`/`"1"`. The
624
698
  match Compass, a follow-up migration aligning both `meta` and `meta-group` to roles 1,3,4 is needed.
625
699
 
626
700
  ## Change history
701
+ - 2026-08-28 — **Corrected the route-level gate's role-id space.** The dispatch check is keyed on
702
+ the role space that `Core.Records.aclDatabase` selects, not always `id.client.roles`: `surfaces`
703
+ is a **CORE** record, so `V2.php` (~L3090) takes `$roleIds` from **`id.core.roles`** and
704
+ `getRecordScriptPhpMethod` (~L6198) matches those **core** ids against
705
+ `Client_<tenant>.AclRecordScripts.roleId`, while the `SurfaceOverrides` cascade uses
706
+ **`id.client.roles`** (Surface.php:53/:108) — two different id spaces in two tables that both
707
+ live in the client DB. Recorded how api2 derives `id.core.roles`
708
+ (`SELECT DISTINCT coreRoleId FROM Roles WHERE id IN (<client roles>) AND coreRoleId IS NOT NULL`,
709
+ ~L1611-1627), which is why any Base-holding user passes the gate and the gate is rarely the
710
+ blocker. Added the **prod** ids for the `surfaces` record (`Core.Records` **333**) and its scripts
711
+ (**28** meta→resolve, **29** debug→metaDebug, **30** meta-group→metaGroup) and flagged that
712
+ `toga25-supply/surface-layer-guide.md` documents dev-sandbox values (record 2300, scripts 25/26/27)
713
+ that must not be reused on prod. Widened the `aclActionId` finding: **no** `SurfaceElement` on
714
+ `appId = 1` declares an `aclActionId`, so the element ACL gate is inert app-wide, not just for
715
+ navigation. Made the cascade precedence exact (`ORDER BY … , id ASC` + last-write-wins map).
716
+ All verified read-only against prod. (bala)
627
717
  - 2026-08-27 — Corrected the reserved-id framing: the blocks record the 2026-08-21 reseed only and are
628
718
  **not** an inventory of all surface ids. Pre-reseed surfaces kept AUTO_INCREMENT ids — the
629
719
  `navigation` TAB_STRIP is `SurfaceElements` **109–114** — so an id outside 125–146 is not evidence it