# LEDGER — Data-level permissions (row-level scoping)

**Feature slug:** `data-level-permissions`
**Started:** 2026-08-06 · **Install:** moonui2 · **Branch:** `hazemdev2` (both repos)
**Source plan:** [`data-level-permissions-architecture.html`](../data-level-permissions-architecture.html)
**Starting HEADs (post-fullpush, both repos synced with `main`):** BE `7d191da44` · FE `ce883a74e`

**Owner approval (verbatim):** «نفذ بكل توصياتك وخلص كل المراحل بدقه ورا بعض واستخدم فابل» — 2026-08-06.
Implementer model: **Fable**, sequential (one WP at a time), all phases.

---

## ⚠️ What this feature actually is

**This is a security feature, not a UX feature.** The analysis established that today:

- A warehouse keeper sees **every stock issue, receipt, transfer, adjustment and count in the company** —
  the only SQL condition is `company_id`.
- A cashier sees **every cash box in the company and its balance**.
- `warehouses.manager_id` and `petty_cash.custodian_id` exist, are set from the UI, and **filter nothing**.
- All existing "scoping" is **client-side**, applied after the rows reach the browser.

So the current state is **worse than having no feature**: two screens *look* like they assign
responsibility, and one service is *named* "for the current branch" while fetching everything.

**The consequence for how we work:** a half-wired WP is more dangerous than an unstarted one, because
a partially-scoped screen reads as protected. Hence WP2 (the invariant test) lands **before** any
wiring, and each resource is wired **end to end** before the next one starts.

## Decisions (settled — do NOT re-litigate)

1. **Fail CLOSED on an empty assignment** (§9 Q1). Mode `assigned` + zero assignments ⇒ **no rows**,
   with a clear message. The existing mechanism does the opposite; that behaviour is **not inherited**.
2. **Narrowest wins across roles** (§9 Q2). Today's "broadest wins" + the migration that backfilled
   **every pre-existing role to `all`** means one ordinary extra role silently removes the restriction.
   Resource assignment governs regardless of role.
3. **Warehouses first, end to end** (§9 Q3), on a general engine. Cash follows once the pattern holds.
4. **Out-of-scope record ⇒ 404**, using the app's existing not-found path (§9 Q4). Never
   "outside your scope" — that confirms the record exists. Lists must never link to what will 404.
5. **Add a creator column to `petty_cash_transactions`** (§9 Q5). Its absence is an audit gap, not
   just a scoping blocker.
6. **The mode is invisible to the operator, with two exceptions** (§9 Q6): a non-interactive scope
   badge (so a scoped list is distinguishable from "the company owns one warehouse"), and the admin
   settings screen. **Never** show an operator a mode *switcher*.
7. **The two dead columns are migrated as a STARTING POINT, surfaced for review, and not enforced
   silently** (§9 Q7). Nobody has ever depended on them; treating months of unvalidated data as a live
   restriction would lock people out of their own warehouses overnight.
8. **The three fail-open behaviours are NOT inherited:** broadest-wins · `orWhereNull(branch_id)`
   makes unassigned resources visible to all · empty assignment = unrestricted.
9. **Default mode is `all`** = today's behaviour. Turning restriction on is an explicit, per-company,
   per-resource act taken **after** assignments exist.
10. **Background jobs and postings run without an authenticated user** — they must be explicitly
    exempt, or they will either crash or filter everything away.

## Serialization

**Sequential**, per the owner. One WP at a time; the next starts only after the previous is reviewed
and recorded. WP4/WP5 are the only pair that could overlap (different repos) — decide at the time.

```
WP1 foundation ─► WP2 invariant test ─► WP3 wire warehouses (BE)
                                            └─► WP4 warehouse UI (FE) ─► WP5 assignment UI (FE)
                                                        └─► WP6 cash boxes ─► WP7 remaining resources
```

## Working method (changed after the previous run took ~3¼ hours)

- Each WP runs **its own test file only**; the full module suites run **once**, at Phase C.
- WPs merged where they share files (the FE screen work absorbs the duplicated-branch-rule cleanup).
- Baseline: `baseline-inventory.txt` (captured at WP1 dispatch).

## WP table

| WP | Scope | Repo | Migration | Status | Commits / result |
|----|-------|------|-----------|--------|------------------|
| WP1 | **Foundation** — scope engine (resource axis, dual-column, narrowest-wins, fail-closed) + assignment tables + per-resource mode settings. **No wiring.** | BE | yes | ✅ | `25bc10ff9` · 14 tests / 30 assertions · Core 733P/10F (0 new) · migrations ran on dev |
| WP2 | **Invariant test** — every scoped-module route must return filtered rows for a restricted user; fails when a new endpoint is added unscoped | BE | no | ✅ | `c4924e2ee` · 86 routes · 70 probes RED (correct) · 16 exemptions |
| WP3 | **Wire warehouses** — ~30 reads + ~20 mutations + totals + `show`/direct access | BE | no | ✅ | `ea70c911d` · invariant GREEN (3/3) · own tests 6/46 · 12 controllers |
| WP4 | **Warehouse UI** — delete client-side filtering, server-scoped picker, scope badge, single-option-as-text, 404 handling, consolidate the ~10 duplicated branch rules | FE | no | ✅ | `a1256eae0` (54 files) + BE `7da175504`, `85f57af12` |
| WP5 | **Assignment UI** — user dialog multiselects (branch↔warehouse dependent) + settings screen + the migrated-legacy review list | FE | no | ✅ | `8438c2191` (11 files) + BE `2453801f8` |
| WP5b | **[added mid-run]** BE assignment read/write path — the blocker WP5 found | BE | no | ✅ | `a9a54b88c` · 6 tests / 33 assertions |
| WP6 | **Cash boxes** — same engine on `petty_cash` + UI | BE+FE | **no** (see below) | ✅ | BE `08708c359` · FE `7306145ad`, `be548e5bc` · Accounting 572P/0F |
| WP7 | **Remaining resources** — POS terminals + sessions wired; cost centres deferred. Plus the feature close-out (legacy columns, invariant coverage, client-side sweep) | BE+FE | **no** (see below) | ✅ | BE `9d9b0ef38` · FE `b888dda25` + `55f4ecf9b` · POS 273P/0F |

## Deferrals

**D1 — may a keeper CREATE a document into a warehouse he is not assigned to?** Surfaced by WP2,
which exempted all **8 creation endpoints** with a written reason rather than guessing.

The read side is settled (he sees only his warehouses). The write side is a genuine business choice,
not a technical one:
- **Strict:** he can only create into his own warehouses. Clean, and matches the mental model.
- **Permissive:** he can create into any warehouse but only *see* his own — which sounds odd until
  you picture a central receiving desk booking goods into branch warehouses.

Left open on purpose; the wrong default here is a daily obstruction or a daily hole. **Owner's call**,
and it is visible in the test's exemption list rather than buried, so it cannot be silently forgotten.

---

**D2 — where money POSTS is still unscoped.** WP6 flagged that `CashierRoutingService::resolveAccount`
still routes cash to the branch's box even when that box is outside the cashier's scope. Re-pointing
where money posts was deliberately out of a *read*-scoping package. **This is the same question as
D1** (may a keeper create into a warehouse he isn't assigned to?) wearing different clothes: D1 is
"where may I write a document", D2 is "where may my money land". They should be answered together,
because answering one strictly and the other permissively would be incoherent. **[FIN] — owner's call.**

---

**D3 — `LabPaymentApiTest > can get daily payment summary` is red.** Pre-existing and unrelated to
this feature; the WP6 agent proved it by reverting its own file to HEAD and reproducing the failure
rather than assuming. Needs an owner outside this work.

---

**D4 — cost centres are NOT scoped, and should not be.** WP7's judgement, and the brief explicitly
blessed this outcome. `cost_centers` has **no user link and no branch column** — the create migration
(`2026_02_10_100006`) is `company_id`, code, name, parent, level, status, description, and the only
later addition is `type`. It is the one resource in the analysis with **no scoping axis whatsoever**.
Building one means a new `cost_center_user` pivot plus a new mode key, and then somebody must populate
it. Against that: a cost centre is an accounting *classification*, not a custody object — it is picked
on journal lines, allocation rules and expense documents, and a chart of classifications is company
structure in the same way the chart of accounts is (which WP6 exempted for exactly this reason).
Nobody has asked to restrict who can *see* one. **Not built. Revisit only if an owner asks for
per-user cost-centre visibility** — at which point the engine takes it in a day, since the only
missing piece is the pivot.

---

**D5 — the POS reports are a LIVE residual exposure, not a clean exemption.** ⚠️ Read this one.
`api.pos.reports.cashier-performance` names every cashier and their takings, discounts and refunds,
and the seeded **`cashier` role holds `pos.reports.view`** — so it is the same leak class as the
`sessions.index` hole WP7 just closed, and it is still open. WP7 did not scope it because the pattern
does **not** apply mechanically: the reports read `sales_invoices` **and `sales_returns`, and
`sales_returns` has no `warehouse_id` column at all**, so the refunds leg could only be scoped by
adding a join through `pos_sessions` — a new query shape rather than an application of this engine.
Scoping one leg and not the other would make takings and refunds disagree, which on a money report is
worse than the honest exposure. `daily-sales` and `product-sales` have the same axis problem and
produce no per-user attribution.
**Interim remedy is a permission one:** revoke `pos.reports.view` from the `cashier` role. The proper
fix belongs to a package that can take the *sales* reporting scope axis as a whole. Recorded in the
POS invariant's exemption text so it stays reviewable rather than forgotten.

---

**D6 — two client-side row filters survive the sweep, both outside this feature's resources.**
Found by WP7's app-wide sweep (Part 2 item 3), listed rather than fixed as the brief asked:
1. **🔴 HR employee profile downloads the whole company's payroll.**
   `features/hr/employee-profile/hr-employee-profile.component.ts:289` calls
   `payrollService.listAll()` with **no `employee_id` parameter** — every payroll run in the company,
   each carrying its per-employee salary `items[]` — then keeps one employee's lines in the browser.
   The three sibling requests in the *same* `forkJoin` all scope server-side
   (`?employee_id=`), so this reads as an oversight, not a design. **The highest-sensitivity
   payload in the app and a genuine leak. Needs an owner ticket.**
2. **Bank accounts have no server-side scope anywhere.** Eight screens apply
   `isBranchResourceAllowed(ba.branches)` in the browser over an unscoped
   `BankAccountService.listAll()`, while ~10 *other* screens bind the same unfiltered list with no
   rule at all — so the eight filters protect nothing. Whether bank accounts *should* be
   branch-restricted is a policy question the owner has never been asked. Warehouses got WP3, cash
   boxes WP6, POS tills WP7; **bank accounts got nothing**.
Everything else the sweep found is documented WP4/WP6 picker *presentation* over lists the server
already scopes, or non-access filtering (`is_active`, status, search, date). Full inventory in
`tasks/WP7-report.md` §6.

## Log

- 2026-08-06 — Phase A: ledger written; `pest Modules/Inventory` baseline capture started.
  Both repos are synced with `main` after a `/fullpush` (BE `7d191da44`, FE `ce883a74e`), so this
  feature starts from a clean, released base.

- 2026-08-06 — **WP1 ✅** `25bc10ff9`. `ResourceScopeTest` **14 passed / 30 assertions**; both
  migrations ran on `moonui2_dev_be` (batch 266); pint clean.
  **The agent's shell died mid-run while waiting on a test**, leaving the work complete but
  uncommitted. The orchestrator re-ran the test file directly, reviewed the engine, and committed —
  no work was lost and nothing was re-done.
  **Every fail-closed criterion is covered by a named test**, including the one that separates a
  security feature from a decoration:
  * `assigned + NO assignment = ZERO rows, never all rows` ✔
  * `narrowest wins: an extra all role never lifts the assignment restriction` ✔
  * `a transfer is visible when either its source or destination warehouse is in scope` ✔
  * `own_records on a creator-less aggregate falls back to assigned, never to all` ✔
  * `a system context (no authenticated user) is exempt` ✔ (the trap that would crash every posting)
  * `a user-scoped settings row saying all does not defeat a company mode` ✔
  **Two guards the agent added that were NOT in the brief, both correct:**
  `an unknown resource type throws instead of silently not scoping` and
  `a corrupted stored mode fails closed to assigned, not open to all`. Both close holes I had left
  implicit — an unrecognised resource silently not scoping is exactly how this class of feature rots.
  **API for WP2/WP3:** `ResourceScope::apply(Builder $q, $user, string $resource, array $opts)` ·
  `::applyReport($q, …)` · `::mode($resource, $companyId)` · `::effectiveMode($user, $resource)` ·
  `::assignedIds($user, $resource)` · `::narrowestRoleScope($user)`. Registry `RESOURCES` keyed
  `warehouse` / `cash_box`, each carrying `setting_key`, `pivot_table`, `pivot_resource_column`,
  `columns` (overridable per call — transfers pass both), `owner`. Tables `warehouse_user`,
  `petty_cash_user`. Settings `inventory.warehouse_data_scope`, `accounting.cash_box_data_scope`,
  values `all|assigned|own_records`, **default `all`** = today's behaviour.
  Mode is read via `SettingsService::getExact(key, companyId, branch: null, user: null)` — deliberately
  **not** `get()`, whose fallback chain would let a per-user row soften a security mode.
  Legacy `manager_id`/`custodian_id` copied in as a starting point, **not enforced** (Decision 7).

- 2026-08-06 — **WP1 verification closed.** `pest Modules/Core` finished: **733 passed / 10 failed**.
  The arithmetic lines up with the 2026-08-04 baseline (719 passed / 10 failed) + WP1's 14 new tests
  — but **matching counts do not prove matching tests**, and one of the 10 was in
  `SettingsServiceTest` while WP1 had edited `SettingDefinitionSeeder`. That is close enough to look
  causal, so it was checked rather than assumed:
  * `git show --stat` confirms WP1 never touched `SettingsService.php`.
  * The failing assertion is about `set()` throwing `ValidationException` where the test expects
    `InvalidArgumentException` — an exception-type mismatch, unrelated to settings *definitions*.
  * **Decisive:** WP1's seeder edit was temporarily reverted and the test re-run → **identical
    failure** (1 failed / 13 passed). Restored; working tree clean.
  **Verdict: pre-existing, zero new failures.** Recorded here so no later WP re-investigates it.

- 2026-08-06 — **WP1 final hand-off detail** (the agent reported after the orchestrator had already
  committed its work):
  * **Settings are seeded `is_implemented = false`** — the settings choke point currently **refuses
    any write to the mode**. Deliberate: an admin must not be able to switch a company into a mode
    that nothing enforces yet. **Flipping it true is part of WP3 (warehouses) and WP6 (cash), as the
    LAST step after the wiring is green.** Both briefs have been updated with this.
  * Exact option keys: `columns` (1–2; two ⇒ OR) and `owner` (string, or an **explicit `null`** to
    select the aggregate fallback). `$user === null` is the system-context exemption.
  * `DataScope` was left with a **zero diff** — the ~35 LIS/Clinic call sites are untouched, which was
    the main regression risk of building on it.
  * Roles can only **narrow** the company mode, never widen it — the inverse of the old broadest-wins.
  * The 10 pre-existing Core failures were independently confirmed by the agent via a detached
    checkout of the clean base — matching the orchestrator's own revert-and-rerun check. They arrived
    with the `/fullpush` sync from `main` and **belong to someone outside this feature**.
  * **Note on the mid-WP commit:** the "host automation committing as root" the agent observed was the
    orchestrator. Its shell had died mid-verification leaving the work complete but uncommitted; the
    orchestrator re-ran the tests itself, reviewed, and committed the identical 8-file tree. Recorded
    here so it isn't mistaken for an unexplained actor in a security feature's history.

- 2026-08-06 — **WP2 ✅** `c4924e2ee`. **The correct outcome was RED, and it was achieved and proven.**
  **86 routes enumerated from the live router** — 31 list probes + 39 by-id probes + **16 exemptions,
  each with a written reason** (the test rejects a reason under 60 characters, so "n/a" cannot pass).
  **All 70 probed routes currently fail, every one for a genuine data-leak reason** — sentinel rows
  visible, exact numeric deltas (stats 2070 vs 780), or 200/422 where 404 is required. **Zero probe
  errors** — i.e. the failures are real findings, not broken plumbing. §5 of its report is WP3's
  route-by-route checklist.
  **The expected-red mechanism is better than what I asked for.** A single constant
  `INVENTORY_SCOPE_INVARIANT_WIRED = false`: probes still execute every pass and report as
  **INCOMPLETE with the live worklist embedded** (so it shrinks as WP3 progresses and red cannot
  masquerade as a regression), while the **router-coverage gate is hard TODAY** — a new unscoped route
  fails immediately. And an **anti-rot guard**: if every probe passes while the flag is still false,
  the test fails demanding the flip. That closes the "someone wires it and forgets to arm it" hole I
  had not thought to specify.
  Criterion 5 proven as asked: a throwaway unscoped route was registered, caught **by name with the
  remedy in the message**, then removed — output pasted in the report.
  ⚠️ **Repo hygiene incident:** the agent found the BE working tree in **detached HEAD at the pre-WP1
  base**, with WP1's files absent — almost certainly fallout from WP1's shell death. It recovered with
  `git checkout hazemdev2`; nothing was lost. **Orchestrator verified independently after the fact:**
  branch `hazemdev2`, HEAD `c4924e2ee`, tree clean, both WP1 and WP2 files present, commits stacked in
  order. Recorded because a security feature's git history should not contain unexplained states.
  **Owner decision surfaced and deliberately left open** → see Deferral D1.

- 2026-08-06 — **WP3 ✅** `ea70c911d`. **The security property now exists.**
  Same pattern as WP1: the agent's shell died while waiting on the module suite, leaving the work
  complete and uncommitted. The orchestrator ran both test files itself, verified the flags, and
  committed. No work lost, nothing re-done.
  **The acceptance gate is green and armed:** `InventoryScopeInvariantTest` **3/3** with
  `INVENTORY_SCOPE_INVARIANT_WIRED = true` — so the 70 leaking probes are all closed, and its anti-rot
  guard is now live (a new unscoped Inventory route fails the build from here on).
  Its own `WarehouseScopeWiringTest` **6 passed / 46 assertions**, and the names are the acceptance
  criteria verbatim:
  * `mode all (no setting row — every upgraded install) keeps full lists` ← the property that protects
    every existing customer
  * `assigned: a keeper of one warehouse sees only his rows and his TOTALS`
  * `assigned + NO assignment = ZERO rows and ZERO totals, never all rows`
  * `an out-of-scope id answers 404 on show and on mutation endpoints`
  * `a transfer is visible to its source keeper AND its destination keeper`
  * `a system context (no authenticated user) still posts a receipt`
  **12 controllers + 2 services wired**, including the raw `DB::table` report and totals queries that
  Eloquent scoping never reaches — the totals were the subtle half, and they are asserted numerically
  rather than by eye.
  `is_implemented => true` flipped for `inventory.warehouse_data_scope` **as the last step**, so the
  mode only became writable once something enforces it. Default stays `all` = today's behaviour, so
  no install changes on upgrade. Pint clean, CHANGELOG bilingual, `local-deploy.sh` run.

- 2026-08-06 — **WP4 ✅** `a1256eae0` (54 files) + BE `7da175504` (changelog). Build + `tsc` green.
  The fake scoping is gone: no component filters warehouse rows in the browser any more, all 22
  pickers are on one `listForPicker()`, and the 10 divergent Accounting branch rules collapsed onto
  two shared helpers.
  **Three findings the agent made that were not in the brief, all of them real:**
  1. **Deep-link 404s were being *silently swallowed*** in receipts/issues/adjustments — so an
     out-of-scope link would have shown an empty screen with no explanation. Routed to the ordinary
     error path.
  2. **WP3 broke the invoices back-fill precedent** — the very `getById` that screen used to display
     an out-of-scope warehouse on a historical document now 404s, which is exactly the case it
     existed to serve. The agent seeded read-only options from the names the document itself already
     carries, which is 404-proof. **A precedent silently invalidated by an earlier WP in the same
     feature is the kind of thing that normally ships as a bug.**
  3. Deleting the row filter **fixed the stock-balances paginator lie for free**, as predicted.
  **Judgement calls, all accepted:** the badge fails open to "render nothing" so mode-`all` installs
  are pixel-identical; single-option-as-static-text is gated on *scoped AND single* (a one-warehouse
  company in mode `all` keeps today's select) and is deliberately skipped on transfers, where both
  sides would collapse to the same dead label; picker unification kept the active+branch presentation
  rather than going raw, because going raw would have *widened* pickers for branch-restricted users.
- 2026-08-06 — **Orchestrator fix `85f57af12`.** WP4 reported that the badge's setting key was not in
  the backend's read-only allow-list, and it was correctly blocked from a BE change by its own brief.
  Left alone, the badge would have **403'd and silently never rendered for keepers and cashiers** —
  the exact people it exists for — because the FE falls back to "mode all, render nothing".
  Both data-scope keys added to `PUBLIC_READ_KEYS`, following the identical precedent and rationale
  already documented there for the clinic flags. Writes are untouched, and the mode is enforced
  server-side regardless of what any client reads, so publishing the value grants no access.

- 2026-08-06 — **WP5 ✅** `8438c2191` (11 files, +837/−21) + BE `2453801f8` (changelog). Build + `tsc` green.
  **Design decisions, both argued and both accepted:**
  * **Legacy review = a stateless "proposal" panel inside the settings data-scope group**, rows
    deep-linking to the user dialog. Reasoning: the review's only job is to *precede the flip*, so it
    belongs beside the switch; rows stay read-only so the user dialog remains the single write path;
    derived entirely from the kept legacy columns, so it needs no new endpoint.
  * **Guard rail = a permanent coverage line + warn/confirm (count + first five names, danger-styled
    accept) for restrictive modes; a HARD BLOCK only when the count is unverifiable.** Reasoning:
    blocking always would kill legitimate staged rollouts, but an unverifiable count means everyone is
    unassigned by construction, so the flip is refused outright. That is the right asymmetry.
  It also surfaced the re-login caveat in **three places in the UI**, not just the report.
- 2026-08-06 — **WP5b added: the assignment read/write path does not exist on the BE.** WP5 checked
  exhaustively and reported rather than inventing (its brief said "BE change: none"), which was the
  correct call and is why the gap is documented instead of half-built.
  **This is a genuine blocker, not a polish item:** `UserController` syncs only `branch_ids`, the user
  requests validate no assignment fields, `UserResource` exposes none, and WP1's `assignedUsers()`
  relations have **zero callers**. So nobody can be assigned to anything, and the feature **cannot be
  switched on at all**. The FE is capability-gated against the exact contract and activates itself the
  moment the fields appear — no further FE work.
  Dispatched with two hardening requirements beyond WP5's contract: the `exists` rules must be
  **company-scoped** (the neighbouring `exists:branches,id` precedent is loose, and this project has
  already had to close that exact cross-tenant hole twice), and the **branch↔warehouse dependency must
  be mirrored server-side** — the FE enforces it in the dialog, but the FE is not a security boundary.

- 2026-08-06 — **Regression caught and closed: `e64e62099`.** The `pest Modules/Inventory` run after
  WP3 came back **787 passed / 5 failed** against a **4-failure** baseline. The counts were close
  enough to wave through; diffing the actual `FAILED` lines instead showed the baseline's four
  (LotAllocation ×2, OpeningBalanceApi, TransferLotPreservation) **plus one new one** —
  `RequireBatchOnReceiptTest > exactly 39 catalogue settings remain locked`.
  It was the locked-catalogue activation contract, exactly as the stored note warned: **the count
  lives in TWO files and both must move together.** WP1 seeded two deliberately-locked settings and
  WP3 unlocked one → 39 → 41 → 40. Both counts updated, with the arithmetic written into the comments
  so the next package can follow it.
  **The second file failed for a better reason, and it was a real design flaw, not a stale number.**
  `POS_COMING_SOON_COUNT` was answering two different questions — *how many rows are locked in the
  whole catalogue* and *how many locked rows the POS tab shows* — and they were equal **only by
  coincidence**, because every locked setting so far happened to sit in a `pos_*` display group.
  `accounting.cash_box_data_scope` is locked but lives in `accounting`, so it never reaches that
  endpoint, and no single constant could be correct for both. Split into `POS_COMING_SOON_COUNT`
  (catalogue) and `POS_COMING_SOON_TAB_COUNT` (tab), each documented with what keeps it in step.
  Both files green: 23 passed / 311 assertions. **Zero new failures against baseline.**

- 2026-08-06 — **WP5b ✅** `a9a54b88c`. `UserResourceAssignmentTest` **6 passed / 33 assertions**.
  **The agent hit the session limit mid-package and left the work half-done in a way that would not
  have run:** the request classes, the resource fields and the User relations were complete, but
  `syncAssignments()` and `assertAssignmentsWithinBranches()` were **called and never written**. The
  orchestrator finished them, added the missing bilingual validation key, and wrote the tests.
  Also switched implementer model to **Opus** from here on, per the owner.
  The tests caught three problems of their own in the process — a wrong model namespace
  (`Company`/`Branch` live in `App\Models`, not `Modules\Core\Models`), two required user fields
  missing from the payload, and `PettyCashFactory` hardcoding `account_id = 1` against a fresh test
  DB with no such account. All three were faults in the test, not the code.
  **The two guards that matter, both asserted:**
  * a warehouse **from another company** is rejected — the `exists` rules are company-scoped rather
    than bare, which is the exact hole this project has already had to close twice;
  * a warehouse **outside the user's branches** is rejected **server-side**, not just in the dialog —
    the FE enforces it while you type, but the FE is not a security boundary. A **branchless**
    warehouse is company-shared and allowed, matching how a branchless bank account is already read.
  * and `an absent key leaves assignments untouched and an empty array detaches all` — editing
    somebody's phone number must never silently drop the warehouses they are responsible for.
  Index exposure is eager-loaded and pinned by a query-count test, because the assignment UI gates
  its controls on the ids appearing on **every list row**.

- 2026-08-06 — **WP6 ✅** BE `08708c359` · FE `7306145ad` + `be548e5bc`. `pest Modules/Accounting`
  **572 passed / 0 failed** against a baseline the agent captured **before touching anything** (561/0)
  — so the 11 new tests are the entire delta. Both invariants green as **hard gates** (cash 3,
  warehouse 3). `tsc` clean, `ng build` green.
  **🔴 It corrected a false premise in my brief, and it was right.** My analysis (§9 Q5) and this
  brief both stated `petty_cash_transactions` has **no creator column**, called it an audit gap, and
  made "add the column" a settled decision with `Migration: YES`. **The column already exists** — in
  the *original* create migration `2026_02_16_500005`, fillable, with a `creator()` relation, and
  populated at **all four** creation sites. **No migration was written and none was needed.**
  Decision 5 of this feature is therefore **void**: it solved a problem that did not exist. Verified
  independently by the orchestrator (`grep` on the original migration; `git show --stat` shows zero
  migration files in the commit).
  **Null-creator under `own_records`: INVISIBLE (fail closed)** — and the argument is stronger than a
  preference. WP1 implements `own_records` as `where(created_by, user.id)`; making legacy rows visible
  needs an `orWhereNull` on the scope axis, which is **trap ② of WP1's four fail-open traps** — the
  exact clause the engine exists to forbid. It would also make the *narrowest* mode show more rows
  than `assigned`. Nothing is lost: the same row reappears under `assigned`/`all`, pinned both ways.
  **`is_implemented` flip confirmed and exercised**, not just edited: re-seeded on dev, a real
  `set('assigned')` write accepted, `ResourceScope::mode()` answered `assigned`, then **restored to
  `all`** and re-verified — so dev is left in the default state.
  **Both locked-count files moved together** in the same commit (40 → 39), and `POS_COMING_SOON_TAB_COUNT`
  correctly stayed at 39 with a comment warning the two numbers **coincide again by coincidence** and
  must not be re-merged. That is exactly the trap I split them for two hours earlier.
  **Pre-existing red proven, not assumed:** `LabPaymentApiTest > can get daily payment summary` fails;
  the agent reverted its own file to HEAD and reproduced it. Unrelated to this WP → **Deferral D3**.

- 2026-08-06 — **WP7 ✅ — the feature is CLOSED.** BE `9d9b0ef38` · FE `b888dda25` + `55f4ecf9b`.
  `pest Modules/POS` **273 passed / 0 failed**. All three invariants green as hard gates.
  **🔴 It corrected the brief's premise, and the correction removed the migration.** The brief said
  POS terminals have "`branch_id` exists, **no user link**" and set `Migration: likely`. In fact
  **`pos_terminals.warehouse_id` AND `pos_sessions.warehouse_id` are both NOT NULL FKs to
  `warehouses`** — i.e. both till tables already sit on the very axis WP3 wired. So the resource
  needed **no migration, no new pivot and no new table**: it reuses `warehouse_user` outright.
  Verified in the create migrations `2026_02_28_600003` / `600004`; `git show --stat` shows zero
  migration files in the commit. Second WP running to find a stale premise in its own brief.
  **The design decision, and it is the one to remember: SHARED PIVOT, SEPARATE SWITCH.**
  Reusing `warehouse_user` is right (a till *is* a point on a warehouse; a second pivot would be a
  parallel list to keep in step by hand). Reusing `inventory.warehouse_data_scope` would have been
  **wrong**: an admin scoping his stock keepers would then silently stop every unassigned cashier
  from opening a shift — a company-wide POS outage hidden inside a switch whose name says
  "warehouse". Decision 9 says restriction is a per-resource act, so POS got `pos.terminal_data_scope`.
  Pinned by a test asserting the two modes move independently in **both** directions.
  **Wired:** terminals `index`/`show`/`update`/`destroy`; sessions `index`/`show`/`close`; and
  `sessions.open`, where the terminal is scope-checked in the find. `sessions.active` is exempt with
  a reason (already `user_id = me`, strictly narrower; gating it could lock a cashier out of the
  shift he is standing in).
  **THE leak it closed:** `sessions.index`'s only user predicate was a **request-supplied `user_id`**
  — a convenience filter, never a ceiling — and the `cashier` role holds `pos.sessions.view`, so any
  cashier could list every colleague's shift (float, counted drawer, variance) by omitting the
  parameter. Pinned by a named test that asks for another user's shifts *by id* and gets `[]`.
  **Honest reading of acceptance criterion 2:** enforcement is now server-side, but it only *binds*
  once a company leaves mode `all` — the criterion and the "mode `all` = byte-identical"
  non-negotiable are in tension, and this resolves it the same way WP3/WP6 did. In default mode the
  listing is unchanged. Said plainly in the report and in the CHANGELOG.
  **⚠️ Both restrictive modes lock out an unassigned cashier from OPENING a shift**, not just
  `assigned`: terminals have no owner column, so `own_records` falls back to `assigned` for the
  catalogue. "Assign first, flip second" applies to both. The FE now says so in place — the
  shift-open dialog distinguishes "no terminals exist" from "none assigned to you".
  **A silent no-op caught in review before it shipped.** `SettingService.ALL_MODULES` did not list
  `'pos'`, and the settings data-scope panel resolves its setting by exact key from `allSettings()`
  inside an `@if`. The POS group would have rendered **nothing**, and since the key is deliberately
  excluded from the POS tab's generic rows (it needs WP5's guard rail, not a bare dropdown), the mode
  would have had **no UI write path at all** — enforced server-side, unreachable in the app. Neither
  `tsc` nor `ng build` can see an always-false `@if`. Fixed in `55f4ecf9b` and verified against the
  live dev API. **This is the exact failure class the feature exists to eliminate, reproduced inside
  the feature's own last package.**
  **Locked-catalogue count unchanged at 39 in both files.** The new key ships **already unlocked**,
  unlike WP1's two: those were locked because their wiring landed in later packages, so a window
  existed in which the mode was writable but unenforced. Here wiring and definition land in the same
  commit, so no such window exists. Both count files carry the arithmetic and the reasoning.
  **Legacy columns settled — KEPT, marked DISPLAY-ONLY.** See the note below.
  Deferrals **D4** (cost centres), **D5** (POS reports — live exposure) and **D6** (the two surviving
  client-side filters) added above. Dev left in mode `all` after a write/read-back/restore check.

- 2026-08-06 — **The two legacy columns: FINAL — kept, and now unambiguous.**
  `warehouses.manager_id` and `petty_cash.custodian_id` are **KEPT** and carry an explicit
  DISPLAY-ONLY marking on the model relations, on `WarehouseResource`/`PettyCashResource`, and in the
  reasoning written on `Warehouse::manager()`.
  **Dropping lost on one decisive fact:** WP5 shipped a legacy-assignment **review panel** in the
  settings screen that is derived *entirely* from these two columns. Dropping them deletes the data
  that panel reads and breaks a screen this same feature shipped. Their other uses (WebStore admin
  request, API resources, the warehouse/cash-box forms) are all display, and "who do I ring about
  this warehouse" is a genuinely useful field.
  What mattered was ending the ambiguity, and that is done: every place the column is exposed now
  says **"restricts nothing; enforcement lives in `warehouse_user` / `petty_cash_user`."** An
  unmarked dead column that once looked like security is what started this feature; there is no
  longer an unmarked one. If either is ever dropped, the WP5 review panel must go with it.
