# WP1 Report — Foundation: ResourceScope engine, assignment tables, mode settings

**Status:** DONE — all acceptance criteria pass; zero regressions (evidence in §6)
**Commit:** `25bc10ff9` on `hazemdev2` (local only — not pushed, not merged)
**Repo/branch:** `/home/moonui2/moon-erp-be` · `hazemdev2` · base `7d191da44`
**Migration run on dev:** yes (`php artisan migrate --force` — the two pivot tables exist; seeders ran)
**Nothing is wired to any controller.** WP3/WP6 do the wiring.

---

## 1. The engine — exact API (WP2/WP3 write against THIS)

New class: `Modules/Core/app/Support/ResourceScope.php` (namespace `Modules\Core\Support`).
`DataScope` is **untouched** — zero diff. ResourceScope is a sibling, not a fork.

### Constants

```php
ResourceScope::MODE_ALL         = 'all'
ResourceScope::MODE_ASSIGNED    = 'assigned'
ResourceScope::MODE_OWN_RECORDS = 'own_records'
ResourceScope::MODES            = ['all', 'assigned', 'own_records']

ResourceScope::RESOURCES = [
    'warehouse' => [
        'setting_key'           => 'inventory.warehouse_data_scope',
        'pivot_table'           => 'warehouse_user',
        'pivot_resource_column' => 'warehouse_id',
        'columns'               => ['warehouse_id'],   // default document column(s)
        'owner'                 => 'created_by',       // default owner column
    ],
    'cash_box' => [
        'setting_key'           => 'accounting.cash_box_data_scope',
        'pivot_table'           => 'petty_cash_user',
        'pivot_resource_column' => 'petty_cash_id',
        'columns'               => ['petty_cash_id'],
        'owner'                 => 'created_by',
    ],
];
```

The resource key strings WP3/WP6 pass to every method: **`'warehouse'`** and **`'cash_box'`**.

### Methods

```php
// Company-wide mode for a resource type. COMPANY-SCOPE-ONLY read (see §4).
// No setting row ⇒ 'all'. Corrupted stored value ⇒ fails CLOSED to 'assigned'
// (+ Log::warning), never open to 'all'.
public static function mode(string $resource, int $companyId): string

// Narrowest data_scope across the user's roles: 'own' < 'branch' < 'all'.
// Null/absent scopes carry no vote; returns null when no role votes.
// (Deliberate inversion of DataScope::effectiveScope()'s broadest-wins.)
public static function narrowestRoleScope($user): ?string

// The mode actually applied for THIS user:
//   company mode 'all'  → 'all' (feature OFF, roles not consulted — the no-op
//                          that keeps every existing install identical to today)
//   company mode 'assigned' + narrowestRoleScope()==='own' → 'own_records'
//   otherwise → the company mode. A role can only NARROW, never widen.
public static function effectiveMode($user, string $resource): string

// Pivot lookup: the resource ids assigned to the user. May be []. Reads the
// pivot via DB::table (Core never imports Inventory/Accounting models).
public static function assignedIds($user, string $resource): array   // array<int>

// Apply the scope to an Eloquent query (list, show, totals, mutations' find).
// $user MAY BE NULL → system context → EXEMPT (no clauses added). See §3.4.
public static function apply(
    \Illuminate\Database\Eloquent\Builder $query,
    $user,                       // authenticated user, or null = system context
    string $resource,            // 'warehouse' | 'cash_box'
    array $opts = []             // see option keys below
): void

// Identical semantics for RAW builders (DB::table / report joins) — the
// mirror of DataScope::applyReportBranch(). Qualify columns when joining
// (e.g. 'columns' => ['inventory_issues.warehouse_id']).
public static function applyReport($query, $user, string $resource, array $opts = []): void
```

### Option keys (for `apply` / `applyReport`)

| Key | Type | Default | Meaning |
|---|---|---|---|
| `columns` | `string[]` (1 or 2) | `RESOURCES[$resource]['columns']` | Resource-id column(s) on the queried table. **Two columns = OR semantics** (transfers). For the resource's own table pass `['id']` (or `['warehouses.id']` when joined). |
| `owner` | `string \| null` | `RESOURCES[$resource]['owner']` (= `created_by`) | Creator column for `own_records`. **Pass `null` explicitly** for creator-less tables (stock balances, movements) → own_records falls back to `assigned` semantics. The engine uses `array_key_exists`, so an explicit `null` is honoured, not defaulted away. |

### Clause behaviour (what each mode adds)

- `all` → nothing (today's behaviour).
- `assigned` → `whereIn(col, assignedIds)`; two columns → `where(fn q => q.whereIn(col1)->orWhereIn(col2))` (nested closure so the OR can't leak). **Empty assignment → `whereRaw('1 = 0')` — ZERO rows.** No `orWhereNull` anywhere.
- `own_records` → `where(owner, user.id)`; with `owner => null` → the `assigned` clause above (never unrestricted).
- `$user === null` → no clauses (system exemption, §3.4).
- Unknown resource string → `InvalidArgumentException` (never a silent no-scope).

## 2. Schema — exact table and column names

### `warehouse_user` (migration `Modules/Inventory/database/migrations/2026_08_06_100000_create_warehouse_user_table.php`)

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `warehouse_id` | FK → `warehouses.id` | `cascadeOnDelete` |
| `user_id` | FK → `users.id` | `cascadeOnDelete` |
| `created_at` / `updated_at` | timestamps | |
| — | `UNIQUE (warehouse_id, user_id)` | |

### `petty_cash_user` (migration `Modules/Accounting/database/migrations/2026_08_06_100100_create_petty_cash_user_table.php`)

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `petty_cash_id` | FK → `petty_cash.id` | `constrained('petty_cash')`, `cascadeOnDelete` |
| `user_id` | FK → `users.id` | `cascadeOnDelete` |
| `created_at` / `updated_at` | timestamps | |
| — | `UNIQUE (petty_cash_id, user_id)` | |

Exactly the `branch_user` / `bank_account_branch` pivot idiom (no `is_primary` — the brief's shape is resource id + user id + unique pair + timestamps; no third idiom, no polymorphic table).

**Legacy copy (non-enforcing):** each migration copies `warehouses.manager_id` / `petty_cash.custodian_id` into its pivot — only pairs whose user row still exists (FK safety), via `insertOrIgnore` (idempotent; tested by running `up()` twice). Copied pairs + count are written to the Laravel log (`Log::info('data-level-permissions: migrated legacy …')`). **The legacy columns are NOT dropped** — WP5's review list can be derived by comparing pivot rows against the still-present `manager_id`/`custodian_id`. On this dev DB both columns held **0 rows**, so the copy was a no-op here; client installs will differ.

**Model relations added** (for WP3/WP5 convenience; the engine itself never uses them):
- `Modules\Inventory\Models\Warehouse::assignedUsers()` → `belongsToMany(User, 'warehouse_user')->withTimestamps()`
- `Modules\Accounting\Models\PettyCash::assignedUsers()` → `belongsToMany(User, 'petty_cash_user')->withTimestamps()`
No relation was added to `User` (kept for WP5 to add if its UI wants it; `ResourceScope::assignedIds()` covers WP3).

## 3. The four fail-open traps — how each is closed (all tested)

1. **Narrowest wins.** `narrowestRoleScope()` ranks `own < branch < all`; `effectiveMode()` lets a role only narrow (`own` tightens `assigned` → `own_records`) and never widen. The exact historical trap — adding a backfilled-`all` role — is a dedicated test: it changes nothing.
2. **No null-resource escape.** There is no `orWhereNull` on the resource axis, in any branch of the engine (and the scoped columns are NOT NULL by schema anyway).
3. **Empty assignment ⇒ ZERO rows.** `assigned` mode with `assignedIds() === []` adds `whereRaw('1 = 0')`. Tested on the Eloquent path AND the raw/report path.
4. **System exemption — the documented rule:** `$user === null` ⇒ system context ⇒ exempt. An anonymous HTTP request can never produce it (scoped routes sit behind auth middleware, so controllers always have a user); only queue workers / artisan / service-level postings pass null. Same convention `SettingsService` already uses for `owner.*` writes. Tested: reads unrestricted with a restrictive mode ON, and a userless write still lands.

## 4. Mode settings — exact keys, and the read variant used

| Key | Module | Type | Default | Allowed | Scope | Permission (auto-derived) | `is_implemented` |
|---|---|---|---|---|---|---|---|
| `inventory.warehouse_data_scope` | inventory | enum | `all` | `all\|assigned\|own_records` | company | `inventory.settings.manage` | **false** |
| `accounting.cash_box_data_scope` | accounting | enum | `all` | `all\|assigned\|own_records` | company | `accounting.settings.manage` | **false** |

- Seeded in `Modules/Inventory/database/seeders/InventorySettingDefinitionSeeder.php` and `Modules/Core/database/seeders/SettingDefinitionSeeder.php` (accounting block), bilingual labels + descriptions, display_group `inventory` (order 25) / `accounting` (order 14). Both seeders ran on dev; verified in DB.
- **Read variant used: `SettingsService::getExact($key, $companyId, null, null)`** — the exact-scope read, company row only, **no fallback chain** — `?? 'all'`. `SettingsService::get()` is deliberately NOT used: its user/branch fallback would let a user-scoped row soften the security mode. The test proves the pair: `get()` WOULD return the softened `'all'`, `ResourceScope::mode()` still answers `'assigned'`.
- **`is_implemented = false` on both** (existing WP9 settings-lock precedent): the settings choke point refuses to WRITE a mode nothing enforces yet, so an admin cannot flip a switch that does nothing. **WP3 must flip `inventory.warehouse_data_scope` to `is_implemented => true` in its seeder change; WP6 does the same for `accounting.cash_box_data_scope`.** (Tests therefore write the `Setting` row via the model, not `SettingsService::set()`.)
- Corrupted stored value (written past the validated choke point) → mode fails **closed** to `assigned` + `Log::warning`, never open to `all`.

## 5. The two documented design decisions

- **`inventory_transfers` (two warehouse columns):** visible when **EITHER** `from_warehouse_id` **OR** `to_warehouse_id` is in scope — both the sending and the receiving keeper legitimately need the row. WP3 passes `['columns' => ['from_warehouse_id', 'to_warehouse_id']]`. Both directions asserted in tests (source-in-scope visible, destination-in-scope visible, neither → hidden).
- **Creator-less aggregates (`inventory_stock_balances`, `inventory_movements`):** `own_records` is inapplicable by nature (no creator column). The caller declares it with `'owner' => null` and the engine **falls back to `assigned` semantics** — the keeper sees his warehouses' balances/movements; with no assignment either, zero rows. Never silently unrestricted. Asserted both ways.

## 6. Acceptance criteria → tests

Test file: `Modules/Core/tests/Feature/ResourceScopeTest.php` — **14 tests, 30 assertions, all passing** (every top-level helper prefixed `resourceScope…`; sqlite `:memory:`; settings cache flushed per test because sqlite ids repeat across rolled-back tests while the array cache survives).

| # | Criterion | Test |
|---|---|---|
| 1 | mode `all` = today | `mode defaults to all and leaves the query untouched` |
| 2 | assigned 2 of 5 | `assigned: a user assigned 2 of 5 warehouses…` (documents AND the warehouses list itself via `columns: ['id']`) |
| 3 | **empty assignment ⇒ ZERO rows** | `assigned + NO assignment = ZERO rows, never all rows` (Eloquent + raw) |
| 4 | narrowest wins | two tests: extra `all` role changes nothing; `own`+`all` → `own_records` result |
| 5 | transfer both directions | `a transfer is visible when either its source or destination…` |
| 6 | own_records + aggregate fallback | two tests (creator filter; `owner => null` → assigned, then → zero) |
| 7 | system exemption | `a system context (no authenticated user) is exempt…` (read Eloquent + raw, and a userless write) |
| 8 | user-scoped row can't soften | `a user-scoped settings row saying all does not defeat a company mode of assigned` |
| 9 | LIS/Clinic untouched | `DataScopeBranchNullTest` + `LisCashierReconciliationScopeTest` re-run green (4 tests, 12 assertions); `DataScope.php` has zero diff |
| 10 | legacy copy idempotent | `re-running the legacy manager_id/custodian_id copy creates no duplicates` (both migrations' `up()` run twice) |
| — | extra fail-closed | unknown resource throws; corrupted mode fails closed; `cash_box` registry + pivot smoke test |

Runs executed (all with `/opt/cpanel/ea-php82/root/usr/bin/php -d memory_limit=1G vendor/bin/pest`):
- `Modules/Core/tests/Feature/ResourceScopeTest.php` → **14 passed (30 assertions)**
- `tests/Feature/DataScopeBranchNullTest.php Modules/LIS/tests/Feature/LisCashierReconciliationScopeTest.php` → **4 passed (12 assertions)**
- `Modules/Core` (full module suite) → **10 failed, 733 passed (4788 assertions)** — run twice, identical result both times.

**The 10 Core failures are pre-existing, not WP1's** — proven, not assumed: the exact same 8 test
files were re-run on a detached checkout of the clean base commit `7d191da44` (WP1 absent entirely)
and the **same 10 tests failed there** (`10 failed, 76 passed` in those files). The failures —
`AdminApiTest` (import-test-data account ids), `BackupSchedulingTest` (FTP UTF-8),
`CountryApiTest` ×2, `ProductCategorySlugTest` (storefront slug), `RoleLifecycleTest` /
`RoleTenantScopingTest` ×2 (cross-company role visibility), `SessionApiTest` (token ip/user_agent),
`SettingsServiceTest` (expects `InvalidArgumentException`, `set()` now throws `ValidationException`)
— all sit in code WP1 never touched. They came in with the `/fullpush` sync from `main` and belong
to Phase C / a separate fix, not to this WP.

## 7. Files touched

| File | Change |
|---|---|
| `Modules/Core/app/Support/ResourceScope.php` | **new** — the engine |
| `Modules/Inventory/database/migrations/2026_08_06_100000_create_warehouse_user_table.php` | **new** — pivot + legacy copy |
| `Modules/Accounting/database/migrations/2026_08_06_100100_create_petty_cash_user_table.php` | **new** — pivot + legacy copy |
| `Modules/Inventory/database/seeders/InventorySettingDefinitionSeeder.php` | + `inventory.warehouse_data_scope` |
| `Modules/Core/database/seeders/SettingDefinitionSeeder.php` | + `accounting.cash_box_data_scope` |
| `Modules/Inventory/app/Models/Warehouse.php` | + `assignedUsers()` |
| `Modules/Accounting/app/Models/PettyCash.php` | + `assignedUsers()` |
| `Modules/Core/tests/Feature/ResourceScopeTest.php` | **new** — 14 tests |

Pint run on touched files only; all files `chown moonui2:moonui2`; `bash local-deploy.sh` run (autoload + optimize:clear + migrate — no new migrations left). No CHANGELOG entry (nothing user-visible until WP3). No FE change. Commit `25bc10ff9` on `hazemdev2` contains exactly these 8 files — not pushed, not merged (`origin/hazemdev2` still at `7d191da44`).

Note for the ledger: the commit was recorded by the host's automation while this WP's verification was still running (author `root`, message matches the WP; the working tree is byte-identical to it — verified with `git diff 25bc10ff9` = empty). Verification then completed green, so the commit stands as-is. An untracked `Modules/Inventory/tests/Feature/InventoryScopeInvariantTest.php` appeared in the tree during this WP — it is WP2's file, was not created by WP1 and is deliberately left uncommitted.

## 8. Notes / hand-offs for WP2–WP6

- **WP3:** pass `'warehouse'` everywhere; transfers need `['columns' => ['from_warehouse_id','to_warehouse_id']]`; balances/movements need `['owner' => null]`; the warehouses list itself needs `['columns' => ['warehouses.id']]` (or `['id']`); `show`/direct access = apply the same scope to the find query → natural 404 (settled decision 4). **Flip `is_implemented => true` on `inventory.warehouse_data_scope` in the SAME WP.** Stamp nothing — there is no operating-resource concept; documents already carry `warehouse_id` from user input.
- **WP6:** same with `'cash_box'`; `own_records` for transactions additionally needs `petty_cash_transactions.created_by` (settled decision 5, out of WP1 scope); flip its `is_implemented` there.
- **WP5:** the migrated-legacy review list = pivot rows joined against the still-present `warehouses.manager_id` / `petty_cash.custodian_id` (the columns were deliberately kept). The migration also logged the copied pairs per install.
- **Settings writes** go through the normal per-key settings endpoint once `is_implemented` is true; permission gates are already auto-derived (`inventory.settings.manage` / `accounting.settings.manage`) and rows exist on dev.
- The engine intentionally has **no request-driven resource filter** (unlike `DataScope`'s `?branch_id`/`X-Branch-Id`): a user-supplied `warehouse_id` filter simply ANDs with the scope ceiling, and an out-of-scope request yields zero rows — fail closed by construction.
