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

**Status: COMPLETE.** 8 work packages (7 planned + 1 added mid-run). Shipped to `hazemdev2` in both
repos. **Nothing pushed, nothing merged** — `/fullpush` is the owner's step. **Nothing
browser-verified** — awaiting the owner's pass.

**Date:** 2026-08-06 · **Install:** moonui2 · **Branch:** `hazemdev2`
**Started from:** BE `7d191da44` · FE `ce883a74e` (both freshly synced with `main`)
**Source:** [`data-level-permissions-architecture.html`](../data-level-permissions-architecture.html)
**Execution record:** [`LEDGER.md`](LEDGER.md) · per-WP detail in [`tasks/`](tasks/)

---

## What this actually was

The owner asked for warehouse keepers to be limited to their own warehouses, then widened it:
«الفكره عامله علي مستوي السيستم كلو … يعني الخزن نفس القصه».

The analysis found something worse than a missing feature:

- A warehouse keeper saw **every stock issue, receipt, transfer, adjustment and count in the company**
  — the only SQL condition on those lists was `company_id`.
- A cashier saw **every cash box in the company and its balance**.
- Any POS user could list **everyone's shifts** — the endpoint filtered by a `user_id` the *client*
  supplied.
- `warehouses.manager_id` and `petty_cash.custodian_id` were settable from the UI and read by
  **zero queries** — two dead columns that looked like they assigned responsibility.
- The only "scoping" that existed ran **in the browser**, after the rows had already been delivered.
- The `data_scope` concept existed end-to-end (role editor → DB → `/me` → cached user → a computed on
  the client) and was **consumed by nothing** outside LIS/Clinic.

So the starting state was not "no protection" — it was **the appearance of protection**, which is worse.

## What shipped

| WP | What | Commit |
|---|---|---|
| WP1 | Scope engine, assignment pivots, per-resource mode settings — **no wiring** | BE `25bc10ff9` |
| WP2 | Router-enumerated invariant test — **deliberately RED** | BE `c4924e2ee` |
| WP3 | Inventory wired: 12 controllers, lists + by-id + raw totals | BE `ea70c911d` |
| WP4 | Warehouse UI: fake scoping deleted, badge, single-option-as-text | FE `a1256eae0` (+ BE `7da175504`, `85f57af12`) |
| WP5 | Assignment UI, mode switch with guard rail, legacy review | FE `8438c2191` (+ BE `2453801f8`) |
| WP5b | **[added]** the assignment read/write path — the blocker WP5 found | BE `a9a54b88c` |
| WP6 | Cash boxes wired, BE + FE **[FIN]** | BE `08708c359` · FE `7306145ad`, `be548e5bc` |
| WP7 | POS tills + shifts wired; legacy columns settled; closeout | BE `9d9b0ef38` |
| — | locked-catalogue count regression | BE `e64e62099` |

### The engine

`Modules/Core/app/Support/ResourceScope.php` — scopes by the **resource** (warehouse, cash box, till)
rather than by branch, because inventory documents carry `warehouse_id` and have **no `branch_id` at
all**. Three modes per resource type: `all` (default = today's behaviour) · `assigned` ·
`own_records`.

**It deliberately does not inherit the four ways the existing branch scoping fails open:**

1. **Narrowest wins**, not broadest — a past migration set *every* pre-existing role to `all`, so under
   the old rule one ordinary extra role silently lifted any restriction.
2. **No null-resource escape.**
3. **Empty assignment ⇒ ZERO rows**, not all of them.
4. **System contexts (`$user === null`) are explicitly exempt** — otherwise every posting and queued
   job would crash or filter itself away.

Plus two guards the WP1 agent added unprompted, both correct: an **unknown resource type throws**
rather than quietly not scoping, and a **corrupted stored mode fails closed** to `assigned`.

The mode is read with `getExact` at **company scope only** — `SettingsService::get()`'s per-user
fallback would otherwise let a user-scoped row soften a security mode.

### The invariant test — the part that outlives us

The decisive finding was structural: **zero global scopes in the codebase**, company scoping
hand-written per controller method (8 times in one controller), and `scopeTenant()` written to
centralise it and barely used. The same team wrote the existing scope helper into ~35 LIS/Clinic call
sites and **zero** Inventory ones.

So there is no seam where a new controller gets scoping for free. `InventoryScopeInvariantTest` (and
its POS sibling) enumerate routes **from the live router**, probe them with a restricted user, and
assert on **returned data** — not on the presence of a method call, which would pass if someone called
it with the wrong arguments. Exemptions each carry a written reason, and the test **rejects a reason
under 60 characters**. A new unscoped route now fails the build.

It landed **red on purpose** (70 probes, every failure a genuine leak) and WP3 turned it green.

## Verification

- **Suites:** run at Phase C — see `phasec-suites.txt`. Per-WP: Core `733P/10F` (the 10 pre-existing,
  arrived from `main`, proven twice) · Accounting `572P/0F` against a 561/0 baseline captured before
  any edit · POS scope tests `15P/66A` · Inventory `787P/5F` → the one new failure was found, diagnosed
  and fixed (`e64e62099`).
- **`ng build` green, `tsc --noEmit` clean.**
- Both invariants green as hard gates.

## Findings the work turned up that were not in the plan

1. **The analysis was wrong about `petty_cash_transactions`.** It claimed there was no creator column
   and made "add it" a settled decision with a migration. **The column has existed since February**,
   fillable, with a relation, populated at all four creation sites. WP6 checked instead of complying;
   no migration was written. **Decision 5 of this feature is void.**
2. **WP3 silently broke a WP4 precedent inside the same feature** — the invoices screen back-filled an
   out-of-scope warehouse by id for display on historical documents, and that `getById` now 404s for
   exactly the case it served. WP4 caught it and seeded read-only options from the document's own data.
3. **Deep-link 404s were being silently swallowed** in receipts/issues/adjustments — an out-of-scope
   link would have shown an empty screen with no explanation.
4. **The scope badge would never have rendered.** Its setting read requires `core.settings.view`, which
   keepers and cashiers do not hold, and the client falls back to "mode all = render nothing". Fixed
   (`85f57af12`) with the same precedent already documented there for the clinic flags.
5. **A single constant was answering two different questions** — "how many settings are locked" and
   "how many locked settings the POS tab shows". They were equal **by coincidence**; the first
   accounting-group locked setting broke it. Split, each documented (`e64e62099`).
6. **The stock-balances paginator lie was fixed for free** by deleting the client-side row filter.

## Owner's acceptance test

**Nothing changes until you switch a mode on.** Every resource defaults to `all` = today's behaviour.

1. **Assign first, flip second.** Settings → assign a user to a warehouse (user dialog, beside
   branches), then switch `inventory.warehouse_data_scope` to `assigned`. The switch shows how many
   users would be left with nothing before it takes effect.
2. Sign in as that user: lists, reports, **totals**, stock card and balances show only his warehouse.
3. Open an out-of-scope record by URL → ordinary not-found, not an empty screen.
4. A user with exactly one warehouse sees it as **text**, not a one-option dropdown.
5. Repeat for cash boxes and POS tills.
6. Set the mode back to `all` and confirm everything returns.

⚠️ **Log out and back in** after changing an assignment — the user and permissions are cached in
`localStorage['cached_user']` at sign-in.

⚠️ **There is no admin bypass** (roles only narrow). Flipping to `assigned` before anyone is assigned
leaves the assignment dialog itself listing zero resources.

## Open — see `LEDGER.md` Deferrals for the full write-up

| | Item |
|---|---|
| **D1 + D2** | **Two halves of one question, and they must be answered together.** D1: may a keeper *create* a document into a warehouse he is not assigned to? (all 8 creation endpoints are exempt-with-reason, visible in the test.) D2: `CashierRoutingService::resolveAccount` still routes money to a branch's cash box even when it is outside the cashier's scope. Reading is settled; **writing and posting are not.** **[FIN] — owner's call.** |
| **D3** | `LabPaymentApiTest > can get daily payment summary` is red — pre-existing, proven by reverting to HEAD, unrelated to this feature. |
| — | **Cost centres deferred deliberately**: no user link and no branch column — the only resource with no scoping axis at all. Building a pivot nobody asked for was judged worse than saying no. |

## Next

Owner runs `/fullpush` (sync + merge to `main` + redeploy), then tests on `/app`.
