# WP6 Report — Cash boxes: the same scope, where the money is

**Status:** DONE — every acceptance criterion pins green; `is_implemented` flipped and verified writable on dev; zero NEW failures.
**Commits (both on `hazemdev2`, NOT pushed, NOT merged):**
- BE `/home/moonui2/moon-erp-be` → **`08708c359`** (13 files, on top of WP5b `a9a54b88c`)
- FE `/home/moonui2/public_html/moon-erp` → **`7306145ad`** (20 files, on top of WP5 `8438c2191`) + **`be548e5bc`** (the single-cash-box static text on the sales cash-sale bar) — **not deployed to `/app`** (orchestrator deploys)

**Arrival check:** both repos ON `hazemdev2`, clean trees, before start and before commit. No detached-HEAD recurrence.

---

## 0. ⚠️ THE BRIEF'S PREMISE WAS STALE — read this first

> «`petty_cash_transactions` has **NO creator column at all**. So mode `own_records` is not merely
> unwired for cash — it is **unbuildable** without a schema change. Settled decision (§9 Q5): **add the column.**»

**That column already exists, and always has.** Verified against the live dev schema and the source:

| Evidence | Where |
|---|---|
| `$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();` | the **original create migration**, `Modules/Accounting/database/migrations/2026_02_16_500005_create_petty_cash_transactions_table.php:22` (last touched by `780b83ade`, long before this plan) |
| Live column present on `moonui2_dev_be` | `Schema::getColumnListing('petty_cash_transactions')` → `[…, journal_entry_id, created_by, created_at, updated_at]` |
| Fillable + relation | `PettyCashTransaction::$fillable` includes `created_by`; `creator()` belongsTo User |
| Populated at **all four** creation sites | `PettyCashTransactionController::store`, `PettyCashBalanceService::decreaseBalance` / `::increaseBalance`, `LabTreasuryController::movement` |

**Consequence: NO migration was written and none was needed.** "Migration: YES" in the brief is void;
nothing new had to run on dev. Everything else in the WP stood unchanged — the audit gap the brief
worried about does not exist for cash (it did for warehouses, which is presumably where the premise
came from). The *decision* about null creators was still live and is taken in §2 below.

On dev today: 6 transactions, **0 with a null creator**; 12 cash boxes; 0 pivot rows.

---

## 1. What landed

### 1.1 The shared layer — a TRAIT, not a base controller

WP3 could hang its four helpers on the base `InventoryController` because warehouses live in one
module. **Cash boxes do not.** The cash surface spans **Accounting** (`PettyCashController`,
`PettyCashTransactionController`), **LIS** (`LabTreasuryController`, `LabPaymentController`,
`LisCashierSessionController`) and **Clinic** (`ReceptionReceiptController` /
`CashierRoutingService`) — classes that share no base. So the one place is a trait:

**`Modules/Accounting/app/Support/ScopesCashBoxes.php`** (namespace `Modules\Accounting\Support`;
LIS and Clinic already depend on Accounting).

| Helper | For | What it fixes |
|---|---|---|
| `scopeCashBox($q, $opts)` | `petty_cash_transactions` (has `petty_cash_id` **and** `created_by`) | defaults; `own_records` filters on the creator |
| `scopeCashBoxCatalogue($q, $opts)` | the `petty_cash` table itself — **no creator column** | forces `columns => ['id']` + `owner => null` ⇒ `own_records` falls back to `assigned` (WP1 §5), never unrestricted. Same rule the warehouses catalogue takes |
| `cashBoxScopeIds(): ?array` | in-memory collections / service signatures | `null` = unrestricted (mode `all` or system) — `[]` = restricted with no assignment ⇒ zero boxes |
| `cashBoxVisibleOr404(int $id)` | a cash-box id supplied in a URL **or a request body** | 404, never 403. **No-op (zero queries) in mode `all`** |

Controllers never call `ResourceScope` ad-hoc — exactly WP3's contract.

### 1.2 Endpoints wired

- **`PettyCashController`** — `index` scoped **before** the request `branch_id`/`is_active` filters, so
  those remain what they always were (convenience filters ANDing under a real ceiling; omitting
  `branch_id` can no longer widen anything). `show`/`update`/`destroy` = scope-in-find ⇒ natural 404.
- **`PettyCashTransactionController`** — the **parent-box fetch is scoped in all four actions**
  (`index`/`store`/`show`/`destroy`), so an out-of-scope box 404s before a single movement is read or
  a single riyal moves. The transaction queries themselves also carry the scope, so under
  `own_records` someone else's movement inside *your* box is invisible and 404s by id.
- **`LabTreasuryController`** — **the sharpest instance in the whole plan.** `treasuries()` filtered
  by `company_id` only; every cashier with the view permission saw every box in the company *and its
  balance*. Now scoped. **The balance derivation plucks its `account_id` list from the already-scoped
  `$list`, so the balances inherit the scope by construction** — WP3's `$totalsBase` trick, applied
  before the aggregate rather than after. `updateTreasury`/`destroyTreasury`/`movement` = scope-in-find
  ⇒ 404. `cashFlow`'s `out.petty_expenses` leg scoped.
- **`LabPaymentController::routing`** and **`CashierRoutingService::getRoutingData`** (new optional
  `?array $visibleCashBoxIds = null`, passed by `ReceptionReceiptController`) — the cash boxes offered
  as a **payment source** are scoped. `null` = today's payload byte-for-byte.
- **`LisCashierSessionController::open`** — a client-supplied `treasury_id` is checked with
  `cashBoxVisibleOr404` (this field had **no validation at all** before — not even a company check),
  and the branch-box default resolution is scoped too.

### 1.3 The boundaries I deliberately did NOT cross (all documented in code)

1. **Posting/routing resolution is not re-pointed.** `$resolved` / `$branchCash` in
   `LabPaymentController::routing`, and `CashierRoutingService::resolveAccount`, still resolve the
   branch cash box exactly as today. Scoping them would silently redirect where a cashier's cash
   **posts** — a financial change nobody asked for. The **picker** is scoped; the **posting default**
   is not. Flagged for the owner in §9.1.
2. **`cashFlow`'s other three OUT legs** (refunds, doctor settlements, external-lab invoices) are lab
   documents with no `petty_cash_id`. The cash-box axis does not apply; scoping them would be
   inventing a second, wrong semantics.
3. **Creation endpoints** (`petty-cash.store`, `treasuries.store`) stay exempt with the WP3 §8
   reasoning — but note the *nested* creation route
   (`POST petty-cash/{id}/transactions`) **is** scoped, because its `{id}` is a read of an existing
   cash box, not a body field on a brand-new document. That divergence from WP3 is deliberate and
   stated in the invariant's exemption text.

---

## 2. THE DECISION: a null-creator row under `own_records` is **INVISIBLE** (fail closed)

The column is nullable and predates consistent population, so a client install may hold movements
with `created_by = NULL`. Under `own_records` such a row **does not appear**.

**Why, and why the alternative loses:**

1. **The alternative is not a config choice — it is a doctrine break.** WP1 implements `own_records`
   as `where(created_by, $user->id)`, which excludes NULLs by SQL semantics. Making legacy rows
   visible requires adding an `orWhereNull` on the scope axis — **trap ② of WP1's four fail-open
   traps, the exact clause the engine forbids** (it is why `DataScope`'s branch axis leaks). Cash is
   the worst place to reopen it.
2. **It inverts the mode's meaning.** `own_records` is the *narrowest* mode. "Legacy rows visible to
   all" would mean the narrowest mode shows every unattributed cash movement to every user —
   more than `assigned` shows. A mode that widens as it narrows is not a mode.
3. **Nothing is lost, and the escape hatch is real and tested.** The same row is visible under
   `assigned` (through its cash box) and under `all`. A supervisor who must review unattributed
   history widens the mode. This is asserted **both ways** in one test: invisible under
   `own_records` (list *and* by-id 404), then visible again after switching to `assigned`.
4. **The honest counter-argument, recorded:** "an old cash movement is history, and hiding history
   from the only person looking at it is worse than showing it." It is defensible — but it argues for
   the person using the *right mode*, not for punching a hole in the narrowest one. And the exposure
   is bounded: on dev the count is **0**, and every write path has stamped the creator since the
   table was created, so the null set is frozen and can only shrink relative to total volume.

**Cost of the decision if a client is affected:** movements older than their creator-stamping era
disappear from a cashier's `own_records` view. The remedy is one settings change, not data surgery.
The CHANGELOG bullet says this in both languages so nobody meets it as a surprise.

---

## 3. The invariant test — extended to a second module, by changing the enumeration *axis*

**New file:** `Modules/Accounting/tests/Feature/CashBoxScopeInvariantTest.php` (self-contained; every
helper prefixed `cashBoxScopeInvariant…`; calls **nothing** from WP2's file — `pest Modules/Accounting`
does not load it and the call would be a fatal undefined-function). Flag:
`CASH_BOX_SCOPE_INVARIANT_WIRED = true`, same expected-red/cannot-rot machinery, second constant as
WP2's report suggested.

**The one design change, and why it was forced.** WP2 enumerated by route-name **prefix**
(`api.inventory.*` = 86 routes). That does not transfer:

- `api.accounting.*` = **246** routes, `api.lis.*` = **455**.
- **The sharpest leak of the whole plan lives under `api.lis.*`**, so an `api.accounting.*` prefix
  would have missed `LabTreasuryController` entirely — the single most important route in this WP.
- Both prefixes = ~700 exemption paragraphs, i.e. a list nobody would ever read. WP2's value is that
  its exemption list is *reviewable*.

So this file enumerates **by reachability**: a route is in scope when its controller's own source
mentions `PettyCash`/`petty_cash`, **or** the controller imports a `Modules\**\{Http\Requests,
Actions, Services}\*` class whose source does.

**The one-level hop is not optional and I nearly missed it.** The Clinic cash surface reaches cash
*only* through `CashierRoutingService` / `ClinicReportService`; a controller-source-only match returns
nothing for Clinic — precisely the silent rot WP2 exists to prevent. The hop is **bounded** to
Requests/Actions/Services on purpose: an unbounded hop matches `App\Models\User` (which relates to
PettyCash) and drags in every authenticated route in the app, proving nothing. Measured: unbounded =
115 noisy routes incl. `api.auth.login`; bounded = the honest set.

**Result: 55 enumerated routes = 14 probed + 41 exempted with written reasons.**

| Probed (list/total axis) | What it pins |
|---|---|
| `api.accounting.petty-cash.index` | the catalogue |
| `api.lis.treasuries.index` | **the company_id-only leak** |
| `api.lis.payments.routing`, `api.clinic.payments.routing` | the payment-source pickers |
| `api.lis.reports.cash-flow` | **numeric** probe on `data.out.petty_expenses` — the list/total agreement |

| Probed (by-id ⇒ 404) |
|---|
| petty-cash `show`/`update`/`destroy`; its transactions `index`/`store`/`show`/`destroy`; LIS treasuries `update`/`movement`/`destroy`; `cashier-sessions.open` |

Two probes needed custom closures rather than the generic builder, and the reasons are written into
the file: `transactions.store` sends a **valid** body (with an invalid one the FormRequest 422s before
the controller ever looks the box up, so the probe would measure validation order, not visibility);
`cashier-sessions.open` carries the box id in the **body**, so it varies the payload, not the URI.
The fixture also carries a **second assigned box** purely so the two `destroy` probes (the LIS door
and the Accounting door onto the same table) each get a live in-scope target.

Exemption groups, each with a full written reason: voucher/expense/revenue documents (30 routes — they
reference a cash box only by GL `account_id`; their picker is `petty-cash.index`, which **is** probed),
chart of accounts (9), user administration incl. the assignment write path (7 — scoping an
administrator by his own boxes would break assignment management), LIS bank accounts (4), cashier
shifts/collections already scoped on the user axis by `LisDataScope` (11), POS settings (2), creation (2).

---

## 4. Acceptance criteria → evidence

**New file:** `Modules/Accounting/tests/Feature/CashBoxScopeWiringTest.php` — **8 tests, 82 assertions**
(helpers prefixed `cashBoxScopeWiring…`).

| # | Criterion | Test |
|---|---|---|
| 1 | **[FIN] mode `all` = today, proved** | `mode all (no setting row, no assignment) leaves every cash screen exactly as it was` — **no setting row at all and no pivot row** (the state of every existing install): all 3 boxes in both doors, cash-flow total = the company-wide **212.0**, both pickers show all 3, by-id 200 on an unassigned box, its movements incl. the **null-creator** one, and a mutation on an unassigned box is not 404. If any default path had gained a clause the empty assignment would have emptied all of it |
| 2 | assigned: list **and** totals agree | `assigned: a cashier of one box sees only that box, and the totals agree with the list` — list/treasuries/both pickers show only his box; `out.petty_expenses` = **42.0**, not 212.0 |
| 3 | empty assignment ⇒ zero, never all | `assigned + NO assignment = zero cash boxes and zero totals, never all of them` — both lists `[]`, both picker `accounts` `[]`, total `0.0` |
| 4 | out-of-scope by id ⇒ 404 | `an out-of-scope cash box answers 404 by id, on reads and on money-moving actions alike` — show, transactions, update, **movement**, **session open**; in-scope twin 200 |
| 5 | own_records creator filter + catalogue fallback | `own_records: a cashier sees only the movements he made, and boxes still fall back to assigned` |
| 5b | **the null-creator decision** | `own_records: a legacy movement with NO recorded creator is INVISIBLE — fail closed` (list + 404 by id, **then visible again under `assigned`**) |
| 5c | creator is stamped on new movements | `every new movement records its creator, so own_records has something to stand on` — through the HTTP path |
| 6 | system context keeps posting | `a userless system context still posts into an UNASSIGNED cash box with a restrictive mode on` — `auth()->user() === null`, `PettyCashBalanceService::increaseBalance` lands; userless read unrestricted (3 boxes) |
| 7 | invariant covers Accounting | `CashBoxScopeInvariantTest` — 3 passed, hard gate |

## 5. Test runs (all `/opt/cpanel/ea-php82/root/usr/bin/php -d memory_limit=1G vendor/bin/pest`)

| Run | Result |
|---|---|
| `Modules/Accounting/tests/Feature/CashBoxScopeWiringTest.php` | **8 passed (82 assertions)** |
| `Modules/Accounting/tests/Feature/CashBoxScopeInvariantTest.php` | **3 passed (4 assertions)** |
| `pest Modules/Accounting` (**baseline, captured BEFORE any edit**) | **561 passed, 0 failed** (1688 assertions) |
| `pest Modules/Accounting` (after) | **572 passed, 0 failed** (1774 assertions) = 561 + the 11 new. **Zero NEW failures; zero failures at all.** |
| `RequireBatchOnReceiptTest.php` + `POSComingSoonSettingApiTest.php` (the two locked-count files, outside the prescribed runs — run explicitly) | **23 passed (311 assertions)** |
| WP2's `InventoryScopeInvariantTest.php` (regression) | **3 passed** — still a hard gate |
| Touched LIS/Clinic surfaces: `LabPaymentApiTest`, `LisCashierReconciliationScopeTest`, `ReceptionCashierTest`, `CashierVarianceJournalTest`, `ReprintReceiptTest`, `VoidPartialReceiptTest` | 47 passed, **1 pre-existing failure** — see below |

**The one red is pre-existing, proven not assumed.** `LabPaymentApiTest > can get daily payment
summary` fails. I copied my `LabPaymentController.php` aside, `git checkout`-ed the file back to HEAD
(WP6 change absent), re-ran that single test — **it fails identically at HEAD** — then restored my
version and re-verified the WP6 tests green. It sits in `dailySummary`, a method this WP never
touched and which reads no cash-box row.

## 6. `is_implemented` flip (the WP1 §4 hand-off) — done LAST, verified end to end

- `Modules/Core/database/seeders/SettingDefinitionSeeder.php` (the **accounting** block lives in the
  Core seeder, not an Accounting one): `accounting.cash_box_data_scope` → `is_implemented => true`,
  with the comment rewritten to say *why* it is now enforceable and to record the null-creator rule.
- Re-seeded on dev (`db:seed --class=Modules\Core\Database\Seeders\SettingDefinitionSeeder --force`).
  Verified: `is_implemented=true` · `SettingsService::set(...,'assigned')` **write OK** ·
  `ResourceScope::mode('cash_box')` answers `assigned` · **restored to `'all'`** and re-verified
  `mode=all`, so the dev install's behaviour is unchanged.
- **Both locked-count files moved together, in the same commit:**
  `Modules/Inventory/tests/Feature/RequireBatchOnReceiptTest.php` `toHaveCount(40)` → **39** (test name
  updated, plus a new `not->toContain('accounting.cash_box_data_scope')` assertion), and
  `Modules/POS/tests/Feature/POSComingSoonSettingApiTest.php` `POS_COMING_SOON_COUNT = 40` → **39**.
  **`POS_COMING_SOON_TAB_COUNT` stays 39** — its comment now warns explicitly that the two numbers
  coincide again *by coincidence* (as they did before WP1) and must NOT be re-merged.
- `PUBLIC_READ_KEYS` needed nothing: WP4's hand-off was already closed by `85f57af12`, which added
  **both** data-scope keys. So the badge reaches the cashiers it exists for.

## 7. Frontend (commit `7306145ad`)

| # | Criterion | What was done |
|---|---|---|
| 5 | no client-side filtering; server-scoped picker; badge; single-option-as-text | See below |
| 6 | the payment-source screens fixed with the **shared** helper, not an eleventh copy | See below |

- **New `CashBoxScopeService`** (`core/services/cash-box-scope.service.ts`) + **`CashBoxScopeBadgeComponent`**
  (`shared/components/cash-box-scope-badge/`), exported from `shared/index.ts`. Siblings of WP4's
  warehouse pair, **not** a parameterisation of it: `WarehouseScopeService` is `providedIn: 'root'`
  with a constructor-triggered one-shot fetch and a private `fetched` latch, so it cannot be injected
  twice with two setting keys without a factory/`InjectionToken` refactor of a shipped, green service.
  Two ~60-line classes beat that. Badge on the Accounting **petty-cash** screen and the **LIS
  treasuries** screen; renders **nothing** in mode `all`; fails open to invisibility on any read error.
- **`PettyCashService.listForPicker()`** — THE one cash-box picker population method, the mirror of
  `WarehouseService.listForPicker()`, with the same layering statement: data scoping is the *server's*
  job (since this WP `listAll()` is already scoped); this adds picker *presentation* only.
  `isWarehouseAllowed(branch_id)` is the correct rule despite its name — `PettyCash` carries a **scalar**
  `branch_id` like `Warehouse`, not a `branches[]` array like `BankAccount`, and a branch-less box is
  shared (WP4's null-branch doctrine). **10 picker call sites** moved off raw `listAll()`: expenses,
  revenues, receipt-vouchers, payment-vouchers, transfers, cash-movement, purchases/payments,
  sales/payments, pos-settings, sales/invoices. The **two admin catalogues** (users assignment dialog,
  settings data-scope panel) deliberately stay on `listAll()` — they need inactive and other-branch
  boxes, which the picker rule strips. **They are NOT a scope bypass**: the *server* scopes
  `petty-cash.index` for everyone, and WP1's engine lets a role only narrow, never widen, so there is
  no admin exemption anywhere. See concern §9.2 — flip the mode only after assignments exist.
- **Single cash box ⇒ static text** (`CashBoxScopeService.singleScopedCashBox()` + the existing
  `.wp4-single-warehouse` class, one global style, no scss copies) on the **sales-invoice cash-sale
  bar** — the only pure cash-box `<p-select>` in the app. Deliberately **not** applied to the
  expenses/revenues/voucher/transfer pickers: those are `account_id`-valued option arrays that **merge
  cash boxes with bank accounts** (`group: 'cash' | 'bank'`), so collapsing them on "one cash box"
  would have to hide the bank options too — wrong. The **LIS request-wizard** treasury select is left
  alone as well: it already auto-selects when a single active box remains (`activePcs[0]`), so the
  single-box case is functionally handled there and a template change would be cosmetic churn on a
  collection screen. In mode `all` `singleScopedCashBox()` returns null, so a company that genuinely
  owns one cash box keeps today's select untouched.
- **The brief's specific finding, fixed at the source:** expenses/revenues/receipt-vouchers/
  payment-vouchers/transfers built their cash-box options with `filter(pc => pc.is_active)` and **no
  branch check at all**, while calling `branchContext.isBranchResourceAllowed(ba.branches)` on bank
  accounts *five lines below*. One helper now, no eleventh copy.
- **Deleted the one true client-side row filter** (`sales/invoices`, the cash-sale block): a strict
  `pc.branch_id === branchId` that also dropped shared branch-less boxes, contradicting every other
  picker. Server scope + the shared presentation rule replace it.
- **`lis-treasuries` was the last screen populating its branch picker from every company branch** —
  moved onto `BranchContextService.filterBranchesForPicker()` (`petty-cash.component.ts` was already on it).
- **i18n:** `CASH_BOX_SCOPE.*` (4 keys, same shape as `WAREHOUSE_SCOPE.*`) added **additively to BOTH**
  `ar.json` and `en.json`, inserted by a script that refuses to write unless `json_decode` succeeds;
  both re-verified with `JSON.parse`. **No git operation ever touched the i18n files.**
- `npx tsc --noEmit` **zero errors**; `npx ng build --base-href /app/` **green** (only the two
  pre-existing CommonJS warnings). **NOT deployed to `/app`.**

**Deliberate mode-`all` behaviour changes on the FE (full disclosure, both are the brief's own asks):**
1. The 10 picker sites now exclude other-branch cash boxes for a branch-restricted user (criterion 6 —
   the missing branch check the brief names).
2. The sales-invoice cash-sale picker now *includes* shared branch-less boxes it used to drop.


## 8. Files touched

**BE (13)** — `Modules/Accounting/app/Support/ScopesCashBoxes.php` **(new)** ·
`Modules/Accounting/app/Http/Controllers/{PettyCashController,PettyCashTransactionController}.php` ·
`Modules/LIS/app/Http/Controllers/{LabTreasuryController,LabPaymentController,LisCashierSessionController}.php` ·
`Modules/Clinic/app/{Http/Controllers/ReceptionReceiptController,Services/CashierRoutingService}.php` ·
`Modules/Core/database/seeders/SettingDefinitionSeeder.php` (the flip) ·
`Modules/Inventory/tests/Feature/RequireBatchOnReceiptTest.php` + `Modules/POS/tests/Feature/POSComingSoonSettingApiTest.php` (40→39) ·
`Modules/Accounting/tests/Feature/{CashBoxScopeInvariantTest,CashBoxScopeWiringTest}.php` **(new)** ·
`docs/moonstack/CHANGELOG.md` (bilingual `[Unreleased]` bullet — user-visible and financial; it states
that nothing changes until an administrator switches the mode, warns about the empty-assignment case,
and names the null-creator rule).

**FE (20)** — `core/services/cash-box-scope.service.ts` **(new)** ·
`shared/components/cash-box-scope-badge/cash-box-scope-badge.component.ts` **(new)** · `shared/index.ts` ·
`core/services/petty-cash.service.ts` · the 10 picker components · `features/petty-cash/*` ·
`features/lis/treasuries/*` · `assets/i18n/{ar,en}.json`.

Pint on touched BE files only (2 auto-fixes: import order + array indentation). Every edited file
`chown moonui2:moonui2`. `bash local-deploy.sh` run.

## 9. Concerns / hand-offs

1. **(Owner decision, → WP7)** **Posting resolution is not scoped.** `CashierRoutingService::resolveAccount`
   and `LabPaymentController::routing`'s `$resolved` still route a cash payment to the *branch's* cash
   box even if that box is outside the cashier's scope. I judged re-pointing where money posts to be
   outside a read-scoping WP. The visible consequence under a restrictive mode: a cashier can collect
   into a box he cannot then see. **This is the cash analogue of WP3 §8's write-path question and it
   should be put to the owner together with it.**
2. **⚠️ (Operational, tell the owner) — assign FIRST, flip SECOND.** There is no admin bypass anywhere:
   `petty-cash.index` is scoped for every user, and WP1's engine lets a role only narrow. So if an
   administrator switches the mode to `assigned` **before** anybody has been assigned a cash box, the
   assignment dialog and the legacy-custodian review panel — which read that same endpoint — list
   **zero boxes**, and he cannot fix it from those screens. This is not new (warehouses shipped with
   the identical property in WP3/WP5) and WP5's UI already implies the order, but on money screens it
   will read as a lockout if nobody says it out loud. The recovery is one settings write back to `all`.
3. **(→ WP7)** The **write path** is still open on both resources: a cashier can create a *new* cash box
   (the creation endpoints are exempt), and an out-of-scope cash `account_id` can still be submitted on
   an expense/revenue/voucher (the store request branch-checks it but does not scope-check it). Both are
   recorded in the invariant's exemption text so they stay reviewable.
4. **Pre-existing red:** `LabPaymentApiTest > can get daily payment summary` fails at HEAD, unrelated to
   this WP (proven in §5). Someone should own it.
5. **Enumeration axis drift.** `CashBoxScopeInvariantTest` matches on the string `PettyCash`/`petty_cash`
   plus a one-level Requests/Actions/Services hop. A future controller that reaches cash through a
   *two*-hop collaborator, or through a raw `DB::table('petty_cash')` in a class that is neither a
   Request, Action nor Service, would not be enumerated. The stale-probe/stale-exemption checks catch
   *removals*; they cannot catch that. If cash grows a new indirection layer, widen the hop list — the
   rule lives in one function (`cashBoxScopeInvariantReachesCash`).
6. **Per-request cost under a restrictive mode:** one small pivot query per scoped sub-query (a cash
   screen ≈ 2). Mode `all` short-circuits before any pivot read, on every helper including the 404 probe.
7. **FE mode caching:** `CashBoxScopeService` fetches the mode once per session, same as WP4's warehouse
   service — a mode flip mid-session shows on next reload, consistent with the existing
   "permissions need re-login" behaviour.
