# Integration Roadmap — Bilal Bin Rabah Platform: Next.js 16 Frontend ↔ Laravel 12 Backend

> Authoritative, self-contained execution plan for wiring the **mock-driven** Next.js frontend (`E:\Companies\Clenicode\bilal\bilal\bilal`) to the **Laravel 12 + Sanctum + spatie/laravel-permission** backend (`E:\Companies\Clenicode\bilal\bilal\bilal-backend`, MySQL). Written for the engineers and AI agents who will execute it. Every decision the prior draft left open is **pinned** here (see §3). Follow the phases in order; each lists real file paths on both sides and explicit acceptance criteria. Do not invent endpoints, fields, casing, or envelope shapes beyond what is fixed here and in the two `CLAUDE.md` files.

---

## 0. Executive summary

**Goal:** turn the mock-driven Next.js frontend into a live client of the Laravel 12 API with **zero component rewrites** — the data-layer seam (`createResourceService`) and the auth seam (`auth-service`) absorb the whole change, flipped per-entity via `NEXT_PUBLIC_LIVE_RESOURCES` and globally via `NEXT_PUBLIC_USE_MOCK_DATA`.

**The contract in one breath:** Laravel-default list envelope `{ data, links, meta }` (snake_case `meta`/query params: `page`/`per_page`/`sort`/`dir`/`search`), camelCase **resource fields** and **422 error keys**, integer BIGINT ids, Sanctum **PAT Bearer** tokens, server-side pagination + sort over whitelisted columns.

**The RBAC model in one breath:** multi-role = **UNION** (combined nav + permissions in one session — **no role switcher, no `X-Session-Role`**); a single seeded **`super-user`** bypasses every permission/policy check (`Gate::before`) **and** the frontend hidden-features mask, is **never assignable**, and is rejected (`422`) if forged; **every** action (CRUD **and** custom) is gated by a `<domain>.<action>` permission + Policy (`403` is the boundary) and the frontend **HIDES** (never disables) controls whose permission is absent; role↔permission **grants** are runtime-editable by the super-user at `/admin/roles` while the permission **catalog** stays code-seeded.

**The profile model in one breath:** every authenticated user gets owner-only `/profile` to edit a **single `phone`** (plus name/email/photo) and change password; roles/permissions/branch/status/linkage are never self-editable. The Employee entity's `phone` + `altPhone` are a **separate concern** edited on the employees screen, not the profile.

**Infrastructure reality:** **no Redis on this server** — cache and queue both use the **`database`** store (`CACHE_STORE=database`, `QUEUE_CONNECTION=database`). The database/file cache driver does **not** support cache tags, so caching uses **explicit namespaced keys** (e.g. `content:home`, `lookups:nationalities`) invalidated with `Cache::forget(key)` on write (optionally a small key-registry to clear a group). Public reads still send `Cache-Control`/`ETag` + Next.js ISR.

**Execution shape:** Phase 0 (env/CORS/contract freeze) → Phase 1 (BE auth & RBAC) → Phase 2 (FE auth) → Phase 3 (data-layer contract) → Phase 4 (RBAC enforcement) → **Phase 4.5 (vertical-slice proof spike on `nationalities`, FE wired live)** → Phase 5 (entity-by-entity buildout) → Phase 6 (cutover & testing). Every entity ships against the shared **Definition of Done** (§4.A), the **CI & quality gates** (§4.B), and the **OpenAPI contract** (§4.C).

## 0.1 Table of contents

- [0. Executive summary](#0-executive-summary)
- [0.1 Table of contents](#01-table-of-contents)
- [1. Context — what this connects](#1-context--what-this-connects)
- [2. The wire contract (fixed)](#2-the-wire-contract-fixed--reconciled-with-backend-claudemd-3-and-laravel-defaults)
- [3. Decisions ratified in this document](#3-decisions-ratified-in-this-document-previously-open--now-pinned)
- [4. Phased plan](#4-phased-plan)
  - [4.A Definition of Done](#4a-definition-of-done-per-entity--per-change)
  - [4.B CI & quality gates](#4b-ci--quality-gates)
  - [4.C API contract / OpenAPI](#4c-api-contract--openapi)
  - [Phase 0 — Environment, CORS & contract freeze](#phase-0--environment-cors--contract-freeze)
  - [Phase 1 — Backend auth & RBAC foundation](#phase-1--backend-auth--rbac-foundation-no-frontend-wiring-yet)
  - [Phase 2 — Frontend auth integration](#phase-2--frontend-auth-integration-token-bearer-401-carve-out-loginlogoutme)
  - [Phase 3 — Data-layer contract upgrade](#phase-3--data-layer-contract-upgrade-envelope--server-pagination--debounce--abort--server-sort)
  - [Phase 4 — RBAC enforcement wiring](#phase-4--rbac-enforcement-wiring-fe-consumes-me-roles-be-policies--scoping)
  - [Phase 4.5 — Vertical-slice proof spike](#phase-45--vertical-slice-proof-spike-first-entity-end-to-end-fe-wired-live)
  - [Phase 5 — Entity-by-entity endpoint buildout](#phase-5--entity-by-entity-endpoint-buildout-dependency-first-incremental-cutover-via-liveresources)
  - [Phase 6 — Cutover & testing](#phase-6--cutover--testing)
- [5. Roles & Permissions](#5-roles--permissions-detailed-and-airtight)
- [6. Aggregate / report data path](#6-aggregate--report-data-path-the-highest-impact-server-pagination-hazard)
- [7. Performance & caching](#7-performance--caching-cross-cutting-discipline--applies-to-every-endpoint--screen)
- [8. End-to-end auth flow](#8-end-to-end-auth-flow-sequence)
- [9. Concrete frontend change list](#9-concrete-frontend-change-list-real-files)
- [10. Concrete backend artifact list](#10-concrete-backend-artifact-list-auth--rbac)
- [11. Risks, edge cases & gotchas](#11-risks-edge-cases--gotchas)
- [12. Final readiness checklist](#12-final-readiness-checklist)

---

## 1. Context — what this connects

- **Frontend:** Next.js 16 App Router. Today it runs entirely on an in-memory mock data layer and hardcoded demo identities (`DEMO_USERS`). There is **no real auth, no token, no server pagination**. Verified facts the plan relies on:
  - `src/lib/services/base/resource-service.ts` — `Identifiable.id: number`; `list()` returns `Promise<T[]>` (no pagination); `ListQuery` has only `search?` + `signal?`; `removeMany` fires **N parallel** `DELETE`s (lines 108–110); mock `list` ignores `signal`; http `list` accepts `signal` but only forwards a `search` param.
  - `src/features/shared/hooks/use-resource-list.ts` — calls `service.list()` with **no args**, deps `[service, reloadKey]`, filters client-side via a `filtered` `useMemo`; returns `{ items, filtered, loading, error, reload, search, setSearch, create, update, remove }`.
  - `src/lib/api/http-client.ts` — thin `fetch` wrapper; **no** `Authorization` header, **no** 401 handling, **no** envelope unwrap; returns `payload as T`; `buildUrl` prefixes `${apiBaseUrl}/api/${apiVersion}`.
  - `src/components/shared/data-table.tsx` — owns `page` state internally (line 34), sorts client-side with `localeCompare('ar')` (line 50), slices `paged` (line 55); **select-all toggles only the current page slice** (line 58).
  - `src/lib/config/env.ts` — exposes only `apiBaseUrl`, `apiVersion`, `useMockData` via the `publicEnv` helper.
- **Backend:** Laravel 12. Sanctum and spatie/laravel-permission are **installed, configured, migrated**. Auth endpoints, role/permission seeds, API Resources, and policies **do not exist yet**. `GET /api/v1/user` currently returns a **raw** model.
- **The switch:** `NEXT_PUBLIC_USE_MOCK_DATA` in `env.ts`. `true` → in-memory dummy services; `false` → HTTP services hitting `${NEXT_PUBLIC_API_BASE_URL}/api/${NEXT_PUBLIC_API_VERSION}` (default `http://localhost:8000/api/v1`). Services pass bare resource paths (`/students`); they never know the version.

---

## 2. The wire contract (fixed — reconciled with backend `CLAUDE.md` §3 and Laravel defaults)

Both sides MUST agree on exactly this. The previous draft invented a `{ data, meta:{…,hasMore} }` camelCase list shape and called it "the Laravel default" — that was wrong. The default Laravel paginated payload is **`{ data, links, meta }` with snake_case meta keys and no `hasMore`**. We adopt **the Laravel default unchanged** (backend §3) and adapt the frontend to it. No custom `ResourceCollection` is needed on the backend.

### 2.1 Casing rule (precise)

| Surface | Casing | Mechanism |
|---|---|---|
| **Resource field names** (payload bodies) | **camelCase** (`nameEn`, `levelId`, `studentCode`) | API Resource `toArray()` maps snake_case DB columns → camelCase. |
| **Framework keys** — pagination `meta`/`links`, list **query params** (`search`, `page`, `per_page`, `sort`, `dir`), filter params (`level_id`, `gender`) | **snake_case** (Laravel convention, backend §3) | Frontend sends/reads these as snake_case. "camelCase on the wire" does **not** extend to framework pagination/query keys. |
| **Validation error keys** (422 `errors`) | **camelCase** | Validate the camelCase payload as received; map camel→snake **in the Action**, not in `prepareForValidation()`. This keeps 422 `errors` keys identical to the frontend field names so inline field errors map 1:1. (Backend §3 permits "explicit mapping in the Action.") |

### 2.2 Envelope & error shapes (literal)

**List (paginated):**
```json
{
  "data": [ { "id": 1, "nameEn": "Ali", "levelId": 3 } ],
  "links": { "first": "…?page=1", "last": "…?page=5", "prev": null, "next": "…?page=2" },
  "meta": { "current_page": 1, "from": 1, "last_page": 5, "path": "…", "per_page": 25, "to": 25, "total": 120 }
}
```
**Single (`get`/`create`/`update`):**
```json
{ "data": { "id": 1, "nameEn": "Ali" } }
```
**Validation error (422):**
```json
{ "message": "بيانات غير صالحة", "errors": { "email": ["البريد الإلكتروني مطلوب"] } }
```
**Other errors (401/403/404/500):** `{ "message": "…" }` (frontend `ApiError` reads `message`).

### 2.3 Frontend `ListResult` mapping (no `hasMore` on the wire — derive it)

```
items   = payload.data
total   = payload.meta.total
page    = payload.meta.current_page
perPage = payload.meta.per_page
lastPage= payload.meta.last_page
hasMore = payload.meta.current_page < payload.meta.last_page   // derived client-side
```

### 2.4 Other contract terms

| Concern | Rule |
|---|---|
| **IDs** | Integer **BIGINT** auto-increment. `Identifiable.id: number` already assumes numeric ids. Never emit string ids/UUIDs. |
| **List query params** | `?search=&page=1&per_page=25&sort=name&dir=asc`. `per_page` default **25**, capped **100** (backend §3). `sort`/`dir` only over **backend-whitelisted** columns. |
| **Auth** | Sanctum **personal access tokens** (token-based, not stateful cookie). `Authorization: Bearer <token>` on every authenticated request. |
| **Multi-role (union)** | A user may hold multiple roles; the session is the **union** of all their roles' navigation + permissions at once. There is **no** active-role selection at login, **no** in-session role switcher, and **no `X-Session-Role` header**. Effective permissions = `getAllPermissions()` (union); **data scoping uses the most permissive held role** — super-user/supervisor → unrestricted, else teacher → own circles, else parent → own children (see §3 decision M1). |
| **Super-user** | One seeded `super-user` account bypasses **both** permission checks (via `Gate::before`) **and** the frontend hidden-features mask. Never assignable via the API/UI; omitted from every role picker (see §3 decision M3). |
| **Versioning** | One segment `v1`, owned by `http-client` (FE) and the `v1` route group (BE). |
| **No magic values** | FE: tokens/`lib/config`/i18n only. BE: `config()` only, never `env()` in app code. |

---

## 3. Decisions ratified in this document (previously open — now pinned)

These were ambiguous or contradictory in the draft. They are decided here so code can be written without further negotiation. Each is reflected in the phases below.

| # | Decision | Choice (pinned) | Rationale |
|---|---|---|---|
| **A1** | Sanctum mode | **Token-based PAT** (localStorage Bearer), not stateful cookie | Matches `localhost:3000 → :8000` SPA; only `http-client` + CORS/Sanctum config would change if reversed later. CORS already permits credentials for a future switch. |
| **L1** | List envelope | **Laravel default `{ data, links, meta }`**, snake_case meta, FE derives `hasMore` | No backend §3 change; no custom collection. |
| **L2** | List query params | **`page` / `per_page` / `sort` / `dir` / `search`** (snake_case) | Backend §3 mandate; FE `createHttpService.list` currently sends only `search` — must be extended. |
| **L3** | Sorting | **Server-side**, moved **together with** pagination; backend whitelists sortable columns | Client `localeCompare('ar')` sorts only the visible page once data is paginated — semantically broken. |
| **L4** | Aggregate/report pages | **Dedicated unpaginated path** (`listAll()` + server aggregate/report endpoints) — see §6 | Default pagination would silently aggregate over page 1 only. |
| **M1** | Multi-role session model | **Union — no role switching.** `/me` returns `roles[]` + `permissions[]`; a user holding multiple roles gets the **combined** navigation + permissions in a **single** session. **No active-role selection at login, no in-session role switcher, and no `X-Session-Role` header.** Effective permissions = the **union** of all held roles (`getAllPermissions()` already unions). **Data scoping uses the most permissive held role:** super-user/supervisor → unrestricted; else teacher → own circles; else parent → own children. FE `filterNavigation()`/`requiredRolesForPath()` resolve against the user's **full `roles[]`** (any-of); `homeForRole` picks a primary home by precedence **super-user > supervisor > teacher > employee > parent**. | A user who is teacher + supervisor should see everything both roles grant at once; forcing a single active role would hide capabilities they legitimately hold and add needless switching UX. Spatie unions permissions natively. |
| **M2** | `EmployeeRole` (job) vs `UserRole` (auth) | **Two separate concepts.** Spatie auth-role names are constrained to the **6 known `UserRole` literals** (`super-user`, `supervisor`, `teacher`, `parent`, `employee`, `student`) — of which only **4 are API-assignable** (`supervisor`, `teacher`, `parent`, `employee`): `super-user` is seed-only (§M3) and `student` is reserved. The employee/user role **picker** offers only `{supervisor, teacher, employee}` (a `parent` user is provisioned via the student-approval flow per §P1, not the employee form). `Employee.roles` (job classification: `teacher`/`secretary`/`accountant`/`supervisor`/`coordinator`/`driver`) stays a **data column**. Job → auth-role map: job `teacher`→auth `teacher` (+circle scope); job `supervisor`→auth `supervisor`; all other jobs→auth `employee`. Per-job capability is expressed as Spatie **permissions** (not extra roles). A test asserts `/me` never returns a role outside the known literals. | The FE only understands the known auth roles; emitting `secretary`/`driver` as Spatie roles would feed `filterNavigation`/`ROLE_HOME`/`requiredRolesForPath` unknown values (undefined nav/home). |
| **M3** | Super-user role | **New `super-user` role that sees and can do everything.** It bypasses **both** permission/policy checks (`Gate::before(fn ($u) => $u->hasRole('super-user') ? true : null)`) **and** the frontend hidden-features mask (Finance/Audit/Statistics and every module visible). **Seeded exactly once** (only one super-user account ever exists), **never assignable via the API/UI**, and **omitted** from the role list shown when adding/editing a user or employee. **Enforcement (concrete artifact, not just an assertion):** the employee/user store **and** update `FormRequest` validate the submitted `roles` with `Rule::in($assignableRoles)` (equivalently `Rule::notIn(['super-user','student'])`), where `$assignableRoles` is the single shared SSoT — so a forged `super-user` (or `student`) yields **`422`**. | A single break-glass/owner identity must transcend RBAC and feature flags without weakening the per-action gates that govern everyone else. |
| **G1** | Per-action gating | **Every endpoint is gated by a `<domain>.<action>` permission + Policy** (index→`viewAny`, show→`view`, create→`create`, update→`update`, delete→`delete`; **`bulk-delete` reuses `<domain>.delete`** — no separate permission). **This extends to every custom/non-CRUD action too** (e.g. `students.transfer`, `students.archive`, `students.pending.approve/reject`, `parents.resetPassword`, `student_exams.publish`, `subjects.assignTeachers`, `employees.assignRoles`, `memorization.record`, `attendance.record`, `grades.record`, `administration.evaluations/warnings/certificates/letters`), each with its own permission + Policy method. `403` is the real boundary. The **frontend HIDES** (never disables) the Add/Edit/Delete/custom-action buttons, row actions, and whole pages (via `RouteGuard`) when the matching permission is absent, reading `permissions[]` from `/me`. | Authorization must be enforced server-side per action; the FE mirrors it purely for UX, hiding rather than disabling so users never see controls they cannot use. |
| **PR1** | Self-service profile | **Every authenticated user gets `/profile`** to edit own data (name, phone, email, photo) and change password (requires current password). `phone` is the **single `users.phone` column** (no multi-phone store/array — the auth User carries exactly one phone, edited on the profile). The **Employee entity keeps `phone` + `altPhone`** as a **separate concern** edited on the **employees screen** (admin), **not** the self-service profile — do not conflate the two. Role/branch/status/permissions/linkage are **admin-only and never self-editable**. Endpoints: `GET /api/v1/auth/me`, `PATCH /api/v1/auth/profile` (self fields only), `POST /api/v1/auth/password` (current+new); all owner-only. The backend profile/password endpoints land in **Phase 1** (alongside `/auth/me`) so the Phase 2 FE profile page has live endpoints to call. | Profile editing is role-agnostic and owner-scoped; it must never become a privilege-escalation path (no role/permission changes through it). |
| **P1** | Auto-password / credential owner | A **Parent `User` row** (`user_type=parent`) is created **at approval** of a pending student, with a **server-generated cryptographic password**, **hashed on store** (Argon/bcrypt), **returned once** for display. Parent logs in with **email OR phone** + that password. FE **stops** generating `autoPassword`; `POST /students/pending` returns **nothing secret**. The "store as-is" option is **deleted**. | Backend §7 mandates "hash on store"; "as-is" is invalid. Resolves email-optional registration (§ parent has no email → phone identifier). Resolves the §6/§7 ambiguity (server issues at approval). |
| **C1** | Per-entity cutover mechanism | Add `NEXT_PUBLIC_LIVE_RESOURCES` (comma-separated allowlist of resource paths) read in `env.ts` and consumed in `createResourceService`: a resource goes **http** when `!useMockData` **or** its path is in `liveResources`; otherwise **mock**. Auth (`auth-service`) is always live and bypasses the factory. | A single global boolean cannot run one entity live while others stay mock; the draft's "flip per vertical slice" was otherwise impossible. |
| **B1** | Bulk delete | Add backend `POST /<resource>/bulk-delete { ids: number[] }` (one transaction, partial-failure reported); FE `removeMany` calls it instead of N parallel `DELETE`s. Select-all = **current page only** by default, with an explicit "select all N matching filter" affordance that issues a server-side bulk-delete-by-filter. | `removeMany`'s N non-transactional requests and undefined cross-page select-all are unsafe under server pagination. |
| **U1** | Backend authenticatable strategy | **Single `users` table + `user_type` discriminator** (`staff`/`parent`/`student`), one `web` guard, Sanctum tokens + Spatie roles for all. No separate guards/models. | Simplest one-token architecture; uniform policy/permission surface. |
| **R1** | Runtime role↔permission management | **A super-user-only `/admin/roles` matrix** edits role→permission **grants** at runtime; the permission **catalog** stays code-seeded (the page never creates/deletes permissions). Endpoints `GET /roles` (roles + held permissions), `GET /permissions` (catalog grouped by domain), `PUT /roles/{role}/permissions` (**sync** + `app(\Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions()`), all gated by a new **`roles.managePermissions`** permission held only by super-user (via `Gate::before`). Editable roles = `supervisor`/`teacher`/`employee`/`parent`; **`super-user` and `student` are never editable/assignable** (rejected via `Rule::in([...])`). FE re-fetches `/me` on save; other users update on their next `/me`/login. See §5.11. | RBAC grants must be tunable in production without a redeploy; the seeder provides the defaults, the matrix tunes them. |
| **R2** | Performance & caching discipline | **Every endpoint eager-loads** all serialized relations (no N+1; `Model::shouldBeStrict()` in non-prod surfaces lazy loads), **indexes** every filtered/sorted/FK column in the migration, and **paginates** (default 25, cap 100; the only unbounded path is the deliberate `listAll()`/report path, §6). Multi-model screens get **composite "one request per screen" endpoints** (§7.2). Public CMS + lookup data is **cached with explicit, namespaced keys + bust-on-write** (`Cache::forget(key)`) and served with `Cache-Control`/`ETag` + Next.js ISR; per-user/role-scoped data is **never** cached. **No Redis on this server** — cache **and** queue both use the **`database`** store (`CACHE_STORE=database`, `QUEUE_CONNECTION=database`); the database/file driver has **no cache tags**, so caching uses explicit keys, never `Cache::tags(...)`. See §7. | Performance is a first-class requirement, not an afterthought; N+1, unindexed filters, and chatty multi-call screens must be designed out up front rather than patched later. |

---

## 4. Phased plan

The cross-cutting standards below (**§4.A Definition of Done**, **§4.B CI & quality gates**, **§4.C API contract / OpenAPI**) apply to **every** phase and every entity. They are referenced from the Phase 4.5 spike, the Phase 5 per-entity acceptance, and the §11/§12 checklists.

### 4.A Definition of Done (per entity / per change)

A backend entity, a frontend slice, or any change is **Done** only when **all** of the following hold. Phase 5 per-entity acceptance and the §11/§12 checklists reference this list verbatim.

- **Tests green.** Backend: a **feature test per endpoint** covering the happy path **and** every error state — **401** (unauthenticated), **403** (missing permission / out-of-scope role), **404** (not found), **422** (validation). Frontend: **vitest** for the affected service/hook/util (incl. error/reload paths). `RefreshDatabase`; seed only what each test needs.
- **Permission-gated end-to-end.** Backend: a `<domain>.<action>` permission **+ Policy** on every action (CRUD **and** custom; `bulk-delete` reuses `.delete`), with `403` as the real boundary; the super-user passes via `Gate::before`; role pickers reject `super-user`/`student` (`422`). Frontend: the matching control/page is **HIDDEN** (never disabled) when the permission is absent from the unioned `permissions[]`.
- **Performance built-in.** The endpoint **eager-loads** every serialized relation (no N+1; `Model::shouldBeStrict()` surfaces lazy loads in non-prod/tests), the migration **indexes** every filtered/sorted/FK column, and the list **paginates** (default 25, cap 100) — the only unbounded path is the deliberate `listAll()`/report path (§6). Multi-service screens get their **composite endpoint** (§7.2).
- **OpenAPI updated.** The entity's paths, params, request bodies, and response schemas are added/updated in the `/api/v1` OpenAPI spec (§4.C) and the spec validates in CI.
- **Lint/format clean.** Backend: `./vendor/bin/pint --test` clean. Frontend: `eslint` clean, `tsc --noEmit` 0.
- **All verification gates pass.** Backend: `php artisan test`, `pint --test`, `php artisan migrate:fresh --seed` (idempotent). Frontend: `tsc --noEmit`, `eslint`, `vitest`, `next build` (exit 0, no `MISSING_MESSAGE`).

### 4.B CI & quality gates

Both repos run CI on every push/PR; **a merge requires every gate green plus the minimum coverage threshold.**

- **Backend pipeline** (`bilal-backend`): `composer install --no-interaction --prefer-dist` → copy `.env` (CI/testing) → `php artisan key:generate` → `php artisan migrate --force` (against the CI DB) → `php artisan test` (with coverage) → `./vendor/bin/pint --test`. Optionally `php artisan migrate:fresh --seed` to prove seeding, and Larastan for static analysis.
- **Frontend pipeline** (`bilal`): `npm ci` → `npx tsc --noEmit` → `npx eslint .` → `npx vitest run --coverage` → `npm run build`.
- **Coverage gate (merge requirement).** Enforce a **minimum coverage threshold** on both pipelines (e.g. lines/statements ≥ an agreed floor); a PR that drops coverage below the floor **fails CI and cannot merge**. Backend coverage via PHPUnit/Pest coverage; frontend via `vitest --coverage` thresholds in the Vitest config.
- **Contract gate.** CI validates the OpenAPI spec (§4.C); a contract change without a spec update fails the build.

### 4.C API contract / OpenAPI

The **machine-checkable** contract for `/api/v1` is an **OpenAPI 3** document, kept in sync with §2 (envelope, casing, query params, error shapes) and extended **per entity** as part of the §4.A Definition of Done.

- **Authoring.** Either hand-author the spec (`bilal-backend/docs/openapi/openapi.yaml`) **or** generate it from annotations/types with a package such as **`dedoc/scramble`** (zero-annotation, infers from FormRequests/Resources) or `darkaonline/l5-swagger`. Pick one approach and keep it the single source.
- **Per-entity sync.** When an entity ships (Phase 4.5/Phase 5), its paths (`GET/POST/PUT/DELETE /<resource>`, `bulk-delete`, any composite/custom endpoint), query params (`page`/`per_page`/`sort`/`dir`/`search` + filters), request bodies (camelCase), and responses (`{ data }`, `{ data, links, meta }`, `422 { message, errors }`) are added/updated in the spec. This step is listed in the per-entity build checklist.
- **CI validation.** CI lints/validates the spec (e.g. `spectral`/`redocly` or the generator's validate command); an out-of-sync or invalid spec fails the build (§4.B contract gate).
- **Optional FE type generation.** Optionally **generate or validate the frontend TS types** (`bilal/src/lib/types/*`) from the spec (e.g. `openapi-typescript`) so the camelCase resource shapes the FE expects stay provably aligned with the backend — drift becomes a build failure rather than a runtime surprise.

### Phase 0 — Environment, CORS & contract freeze

**Goal:** both apps talk locally; the §2 contract and §3 decisions are committed to both repos; nothing wired yet.

**Backend (`bilal-backend/`)**
- `config/cors.php` — verify `paths` includes `api/*`, `allowed_origins` reads `FRONTEND_URL` (default `http://localhost:3000`), `supports_credentials` true. (No code change expected; confirm.)
- `config/sanctum.php` — confirm `stateful` includes `localhost:3000`, `guard = web`; set a finite `expiration` (recommended `525600` = 1 year) and `SANCTUM_TOKEN_PREFIX` (optional, e.g. `bilal_`). Surface `expiresAt` in the auth response (Phase 1).
- `.env` (+ update `.env.example`) — `APP_LOCALE=ar`, `FRONTEND_URL`, `SANCTUM_STATEFUL_DOMAINS`, DB creds, and — **no Redis on this server** — `CACHE_STORE=database` and `QUEUE_CONNECTION=database` (run `php artisan cache:table` + `queue:table` migrations). All read via `config()` only.
- `routes/api.php` — confirm `GET /api/v1/health` returns `200 ok`.

**Frontend (`bilal/`)**
- `src/lib/config/env.ts` — add typed keys via `publicEnv`:
  - `authTokenKey` (`NEXT_PUBLIC_AUTH_TOKEN_KEY`, default `bilal.auth.token`)
  - `liveResources` (`NEXT_PUBLIC_LIVE_RESOURCES`, default `''`) parsed to `string[]` (split on `,`, trimmed). Keep `apiBaseUrl`/`apiVersion`/`useMockData`. **Never** read `process.env` elsewhere.
- `.env.local` (+ `.env.example`) — `NEXT_PUBLIC_API_BASE_URL=http://localhost:8000`, `NEXT_PUBLIC_API_VERSION=v1`, `NEXT_PUBLIC_USE_MOCK_DATA=true`, `NEXT_PUBLIC_LIVE_RESOURCES=`.
- `src/lib/config/app-config.ts` — add `SEARCH_DEBOUNCE_MS` (e.g. `300`) and confirm a single default `PAGE_SIZE` (e.g. `25`, matching backend default) used by `useResourceList`/`DataTable`.

**Acceptance**
- `curl http://localhost:8000/api/v1/health` → `200`.
- Browser `fetch('http://localhost:8000/api/v1/health')` from `localhost:3000` succeeds with no CORS error.
- §2 contract table + §3 decisions committed to both repos' `CLAUDE.md` (or a shared `INTEGRATION.md`), including the multi-role **union** model (no `X-Session-Role`, no in-session role switching), the seed-only `super-user` role, the per-action gating rule, and the self-service `/profile` endpoints.

---

### Phase 1 — Backend auth & RBAC foundation (no frontend wiring yet)

**Goal:** a tested `/api/v1/auth/*` surface plus seeded roles/permissions, returning the exact §2 envelope/casing.

**Artifacts (create at these paths)**
1. **Migration** `database/migrations/<ts>_add_domain_fields_to_users_table.php` — add `username` (nullable, unique), `phone` (nullable, indexed), `user_type` (string: `staff`|`parent`|`student`), `archived_at` (nullable timestamp — **archive flag, not `SoftDeletes`**, per backend §5). Reversible `down()`.
2. **`app/Models/User.php`** — update **`$fillable`** (`name`, `username`, `email`, `phone`, `password`, `user_type`, `archived_at`), **`$casts`** (`password` => `'hashed'`, `email_verified_at` => `'datetime'`, `archived_at` => `'datetime'`), **`$hidden`** (`password`, `remember_token`). Keep `HasApiTokens` + `HasRoles`. Add `parent`/`children`/`teacherCircles` relationships and a `scopeNotArchived`.
3. **`database/seeders/RoleSeeder.php`** — `Role::firstOrCreate(['name'=>$n,'guard_name'=>'web'])` for **`super-user`, `supervisor`, `teacher`, `employee`, `parent`** (+ `student` reserved). Idempotent. `super-user` is created here but is **seed-only** — never granted via the API/UI (M3).
4. **`database/seeders/PermissionSeeder.php`** — `Permission::firstOrCreate(...)` for every entry in §5.5; attach to roles via `Role::findByName($r)->givePermissionTo([...])`. Idempotent; call `app(\Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions()` at the end. **`super-user` needs no explicit grants** — it passes every check via `Gate::before` (item 14).
5. **`database/seeders/UserSeeder.php`** + refactor **`database/seeders/DatabaseSeeder.php`** — order: `RoleSeeder` → `PermissionSeeder` → `UserSeeder`. Create demo users mirroring FE `DEMO_USERS` (supervisor, teacher with circle 1, parent with one child) and `assignRole(...)`, **plus exactly one `super-user` account** (`firstOrCreate` so it stays a single seeded identity; never created via the API/UI). `migrate:fresh --seed` reproduces a login-ready demo.
6. **`app/Http/Requests/Api/V1/Auth/LoginRequest.php`** — rules: `login` (string, required — email **or** username **or** phone), `password` (required); **no** `prepareForValidation()` camel→snake here (keep error keys camelCase); Arabic messages via `lang/ar/`.
7. **`app/Http/Requests/Api/V1/Auth/ForgotPasswordRequest.php`** — `email` rule; generic flow (no user enumeration).
8. **`app/Http/Resources/UserResource.php`** — camelCase output: `{ id, name, username, email, phone, photoUrl, userType, roles: string[], permissions: string[], teacherId?, circleIds?, parentId?, childIds?, archivedAt }`. `roles` = `$this->getRoleNames()` (may include `super-user` for the seeded account); `permissions` = `$this->getAllPermissions()->pluck('name')` (**union across all held roles** — M1). Eager-load `with('roles.permissions')` to avoid N+1. **Assert roles ⊆ the 6 known `UserRole` literals** (`super-user` + the 5) — M2.
9. **`app/Actions/Auth/AuthenticateUserAction.php`** — resolve user by `where('email',$login)->orWhere('username',$login)->orWhere('phone',$login)`, `Hash::check`, reject archived users, `createToken($deviceName, $abilities)` in a DB transaction. Returns `{ user, token, expiresAt }`. Supports staff (email/username) **and** parent (email/phone) — same flow.
10. **`app/Http/Controllers/Api/V1/AuthController.php`** — thin: `login(LoginRequest)` → `{ data: { token, expiresAt, user: UserResource } }`; `logout(Request)` → `currentAccessToken()->delete()`, `204`; `me(Request)` → `UserResource`; `forgotPassword(ForgotPasswordRequest)` → password broker, generic success; **`updateProfile(UpdateProfileRequest)` (`PATCH /auth/profile`, self fields only)** → `UserResource`; **`changePassword(ChangePasswordRequest)` (`POST /auth/password`, current+new)** → `204`. The profile/password methods are **owner-only** and **never** mutate roles/permissions/branch/status/linkage (PR1/D4). (A dedicated `ProfileController` is acceptable instead of folding these into `AuthController`.) **Replace the raw `GET /api/v1/user`** with `GET /api/v1/auth/me`.
11. **`app/Http/Requests/Api/V1/Auth/UpdateProfileRequest.php`** — validate **only** the self-editable fields (`name`, `phone` — single column, `email`, `photo`); `authorize()` is **owner-only** (acts on `$request->user()`); explicitly **forbids** any `role`/`roles`/`permission`/`permissions`/`branch`/`status`/linkage key (PR1/D4); Arabic messages; camelCase error keys.
12. **`app/Http/Requests/Api/V1/Auth/ChangePasswordRequest.php`** — `currentPassword` (must match the stored hash, e.g. `current_password` rule) + `newPassword` (confirmed, strong); `authorize()` owner-only; Arabic messages; camelCase error keys.
13. **`routes/api.php`** (inside `v1`):
    ```php
    Route::prefix('auth')->group(function () {
        Route::post('login', [AuthController::class, 'login'])->middleware('throttle:login');
        Route::post('forgot-password', [AuthController::class, 'forgotPassword'])->middleware('throttle:login');
        Route::middleware('auth:sanctum')->group(function () {
            Route::post('logout', [AuthController::class, 'logout']);
            Route::get('me', [AuthController::class, 'me']);
            Route::patch('profile', [AuthController::class, 'updateProfile']);   // PR1/D4 — owner-only, self fields
            Route::post('password', [AuthController::class, 'changePassword']);  // PR1/D4 — current + new
        });
    });
    ```
14. **Rate limiter + super-user gate** — in **`app/Providers/AppServiceProvider.php::boot()`**: register `RateLimiter::for('login', fn($r) => Limit::perMinute(5)->by($r->input('login').'|'.$r->ip()))`; and register the **super-user bypass** `Gate::before(fn ($u) => $u->hasRole('super-user') ? true : null)` so a super-user passes every permission/policy check unconditionally (M3). (Laravel 12 has **no `Kernel.php`**; middleware aliases, if any, go in **`bootstrap/app.php`** `->withMiddleware(...)`.)
15. **Lang** — `lang/ar/validation.php`, `lang/ar/auth.php`, `lang/ar/passwords.php` (Arabic messages for login/reset).
16. **Tests** — `tests/Feature/Auth/LoginTest.php` (happy, invalid email, wrong password → 401/422), `LogoutTest.php` (401 without token; token revoked after), `MeTest.php` (returns user + roles + permissions; 401 unauth; asserts only known role names), **`ProfileTest.php`** (PATCH `/auth/profile` updates own `name`/`phone`/`email`/`photo`; POST `/auth/password` requires the correct current password; both are **owner-only** and a payload trying to set `role`/`permission`/`branch`/`status`/linkage **cannot** change them — 422 or silently ignored). `RefreshDatabase`; seed only what each test needs.

**Acceptance**
- `POST /api/v1/auth/login` (seeded supervisor) → `200` `{ data: { token, expiresAt, user: { roles:["supervisor"], permissions:[…] } } }` in camelCase.
- `GET /api/v1/auth/me` with that bearer → same user; `401` without it.
- `POST /api/v1/auth/logout` revokes the token; reusing it on `/me` → `401`.
- A user with two roles returns `roles:["teacher","supervisor"]` and a **unioned** `permissions[]`; a test confirms no role name outside the 6 known literals (`super-user` + the 5) is ever emitted.
- The seeded **super-user** logs in and `getAllPermissions()` is irrelevant to its access: a test confirms `Gate::before` lets it pass an arbitrary `$this->authorize('anything')` check; only **one** super-user row exists after `migrate:fresh --seed`.
- `PATCH /api/v1/auth/profile` (bearer) updates the caller's own `name`/`phone`/`email`/`photo` and returns the updated `UserResource`; a payload attempting to set `role`/`permission`/`branch`/`status`/linkage does **not** change them. `POST /api/v1/auth/password` succeeds only with the correct `currentPassword`; both are `401` without a token and owner-scoped (a user can only edit themselves). These endpoints ship **here in Phase 1** so the Phase 2 FE `/profile` page has live targets.
- `php artisan migrate:fresh --seed` idempotent; `php artisan test` + `pint --test` green.

---

### Phase 2 — Frontend auth integration (token, Bearer, 401 carve-out, login/logout/me)

**Goal:** real login/logout/session persistence against Phase 1 while the rest of the app **stays on mock data**. Isolates auth correctness from data-layer changes.

**Files**
1. **`src/store/use-app-store.ts`** — add `token: string|null`, `isAuthenticated: boolean`, `isBootstrapping: boolean`; actions `setSession({ user, token })`, `clearAuth()`. **No `setSessionRole`** — multi-role is a **union, not a switch** (M1). Persist `token` to `localStorage[env.authTokenKey]` on set; restore on init. Extend `AppUser` with `roles: UserRole[]` (the **full** set the user holds) and `permissions: string[]` (the **union** from `/me`); keep a derived single `role` (the primary, by precedence `super-user > supervisor > teacher > employee > parent`) for legacy single-role call sites. `roles?` stays optional so single-role mock `DEMO_USERS` still work (fall back to `[role]`). **Mock-mode reconciliation:** when `env.useMockData`, the demo `loginAs()` buttons set a **sentinel token** (`'mock-session'`) via `setSession` so `isAuthenticated` is `true`. Keep `DEMO_USERS` behind the mock flag only — they must **not** satisfy real login.
2. **`src/lib/auth/auth-service.ts`** *(new)* — `login(login, password)`, `logout()`, `me()`, `forgotPassword(email)`, **`updateProfile(fields)` (`PATCH /auth/profile`)**, **`changePassword(currentPassword, newPassword)` (`POST /auth/password`)** against `/auth/*` via `httpClient`. Returns unwrapped payloads (`login` → `{ user, token, expiresAt }`; `me`/`updateProfile` → `user`). **Separate** from `createResourceService` (auth is not a CRUD resource) and **always live** (does not consult `useMockData`/`liveResources`).
3. **`src/lib/api/http-client.ts`**:
   - Inject `Authorization: Bearer <token>` (read from store/`localStorage`) when a token exists; merge over existing headers. **No `X-Session-Role` header** — the backend derives access from the user's full role set (M1).
   - **Envelope unwrap:** add `getList<T>()` returning `{ items: T[]; meta }` (no unwrap of the list shape — pass `data`+`meta` to the factory). `get/post/put/patch` unwrap `payload.data`. Keep a raw escape hatch for `/health`.
   - **401 carve-out (critical):** call `clearAuth()` + redirect to `/login` **only when** (a) a token **was attached** to the request **and** (b) the path is **not** `/auth/login` or `/auth/forgot-password`. For those endpoints, let `401`/`422` propagate to the page for inline Arabic error display. Always `throw new ApiError(...)`. Redirect via a registered handler (no React-Router import in the transport layer). Keep `ApiError`/`buildUrl`/version prefixing.
4. **`src/app/(auth)/login/page.tsx`** — replace the 800ms simulation + `DEMO_USERS.supervisor` with `authService.login()`. On success: `setSession({ user, token })` straight from `/me`'s `roles[]`/`permissions[]` (**no role-selection step** — the session is the union of all held roles, §5.2/M1), then `router.push(homeForRole(roles))`, which resolves the primary home by precedence `super-user > supervisor > teacher > employee > parent`. On error: `401`→invalid-credentials, `422`→field errors, network→generic, via i18n.
5. **`src/components/layout/topbar.tsx`** — replace the hardcoded `Link to="/login"` with an `onClick` → `authService.logout()` → `clearAuth()` → redirect. On network failure, still `clearAuth()` (fallback). Add a loading state; preserve mobile icon-only styling.
6. **`src/app/(auth)/forgot-password/page.tsx`** — `authService.forgotPassword(email)`; generic success regardless (no enumeration); error state.
7. **`src/components/layout/route-guard.tsx`** — order: (a) if `isBootstrapping` → render nothing/spinner (**do not redirect during `/me`**); (b) **auth check** — if `!isAuthenticated` → `/login`; (c) existing `isHiddenRoute()` + `requiredRolesForPath()` role check. In mock mode the sentinel token (decision above) makes `isAuthenticated` true, so demo buttons keep working.
8. **Session bootstrap** — `src/app/(dashboard)/layout.tsx` (+ new `src/lib/auth/use-auth.ts`): on mount, **skip when `env.useMockData`**; otherwise if a token exists, call `authService.me()` to repopulate `currentUser` with the **full `roles[]` + unioned `permissions[]` + scope fields**; on `401`, `clearAuth()`. Gate render on `isBootstrapping`. Optional `storage` event listener to sync logout across tabs.
9. **i18n** — `i18n/messages/ar/auth.json` (fill `ar` only; defer `en`): keys for invalid-credentials, validation, network, session-expired, login-required, logout-failed (**no `role-select` key** — there is no role selection). Add `i18n/messages/ar/profile.json` (own-data fields + change-password copy). Register `auth` **and** `profile` namespaces in `i18n/request.ts` `DOMAIN_NAMESPACES`.
10. **`src/app/(dashboard)/profile/page.tsx`** *(new)* — self-service profile for **every** authenticated user (PR1/D4): an own-data form (name, single phone, email, photo) calling `authService.updateProfile()` (**`PATCH /auth/profile`**, built in Phase 1), and a change-password form (current + new + confirm) calling `authService.changePassword()` (**`POST /auth/password`**, built in Phase 1). **No** role/branch/status/permissions/linkage controls (admin-only, never self-editable). On success refresh `currentUser` via `setSession`/`me()`; loading/empty/error states; Arabic copy via the `profile` namespace. Lives under the already role-agnostic `/profile` prefix — any authenticated user, owner-scoped (§5.4, §11).

**Acceptance**
- Login with seeded credentials stores a token, lands on `homeForRole(roles)` (primary by precedence), and **survives refresh** (restored via `/me`).
- A multi-role user (e.g. teacher + supervisor) lands in a **single** session with the **combined** navigation/permissions — **no** role-selection prompt and **no** role switcher anywhere.
- Wrong credentials show the Arabic invalid-credentials message inline; the 401 interceptor does **not** redirect or loop on `/login`.
- Logout revokes server-side and clears local state; back button cannot re-enter the dashboard.
- A forced `401` on an authenticated request (revoke token in DB, then navigate) redirects to `/login` and clears state.
- `/profile` loads for any authenticated user; editing name/phone/email/photo persists via `PATCH /auth/profile`, and changing the password requires the current password (`POST /auth/password`); neither path can alter roles/permissions.
- Mock mode still serves all dashboards (flag `true`, demo buttons work via sentinel token).

---

### Phase 3 — Data-layer contract upgrade (envelope + server pagination + debounce + abort + server sort)

**Goal:** FE data layer speaks `{ data, links, meta }` + server pagination/sort, **with mock mode honoring the same `ListResult` contract** so UI code paths are identical. Still no live entities (flag stays `true`, `liveResources` empty).

**Files**
1. **`src/lib/services/base/resource-service.ts`**:
   - Add `ListResult<T> = { items: T[]; total: number; page: number; perPage: number; lastPage: number; hasMore: boolean }`.
   - Extend `ListQuery` with `page?`, `perPage?`, `sort?`, `dir?: 'asc'|'desc'` (keep `search?`, `signal?`).
   - Change `list()` → `Promise<ListResult<T>>`. Add **`listAll(query?)`: `Promise<T[]>`** for aggregate/report consumers (§6).
   - `createMockService.list` — apply `search` → optional `sort` (`localeCompare('ar')`) → slice `(page-1)*perPage … page*perPage`; compute `total`, `lastPage`, `hasMore`; honor `signal`; keep `MOCK_LATENCY_MS`. `listAll` returns the full filtered (unsliced) collection.
   - `createHttpService.list` — **send snake_case params** `{ search, page, per_page, sort, dir }` and `signal` to `httpClient.getList`; map `{ items, meta }` → `ListResult` (derive `hasMore = meta.current_page < meta.last_page`). `listAll` calls the dedicated report path or `?per_page=all` (§6). `getById/create/update` rely on central `.data` unwrap. `removeMany` → `POST <resourcePath>/bulk-delete { ids }` (B1).
   - **Per-resource live override (C1):** `createResourceService` returns http when `!env.useMockData || env.liveResources.includes(config.resourcePath)`.
2. **`src/features/shared/hooks/use-resource-list.ts`**:
   - Add `page`/`setPage` (default 1), `pageSize` (from `app-config.PAGE_SIZE`), `total`, `lastPage`, `hasMore`, `sort`/`dir` state.
   - **Debounce** `search` by `app-config.SEARCH_DEBOUNCE_MS`; **thread `AbortSignal`** into `service.list({ search, page, perPage, sort, dir, signal })`; add `search`/`page`/`sort`/`dir` to the effect deps; **abort** the prior request on change.
   - **Reset `page → 1`** whenever `search`/`sort`/`dir` change (avoids "page 2 of a new filter").
   - In `.catch`, ignore `AbortError`; let the global handler own `ApiError.status === 401`; otherwise set the error state.
   - **Remove the client-side `filtered` `useMemo`** (server filters now). New return: `{ items, total, page, setPage, pageSize, lastPage, hasMore, sort, dir, setSort, loading, error, reload, search, setSearch, create, update, remove }`.
3. **`src/components/shared/data-table.tsx`** — become **fully controlled**: props `data: T[]`, `total`, `currentPage`, `onPageChange`, `pageSize`, `sort`, `dir`, `onSortChange`. Remove internal `page` state, internal slicing, and the `localeCompare('ar')` sort. Header sort buttons call `onSortChange`. Keep zebra/RTL/horizontal-scroll/a11y (`aria-sort`). Select-all = current page by default; add the optional "select all N matching" affordance (B1).
4. **`src/components/shared/pagination.tsx`** — receive `totalPages = ceil(total / pageSize)` + `currentPage` + `onPageChange` (API otherwise unchanged).
5. **`src/components/shared/error-state.tsx`** — accept optional `status`; show "session expired / login required" for `401` and **hide the retry button** for `401` (retry without a new token re-fails).
6. **`src/lib/services/index.ts`** — re-export updated `ResourceService<T>` / `ListResult<T>` types (no logic change). Concrete services (`students-service.ts`, `employees-service.ts`, …) inherit the factory — **no per-service change**.

**Blast radius to migrate (consumers of the old `filtered`/`list()` shape — audit & update):** `src/features/students/hooks/use-students.ts`, `use-circles.ts`, and the `transport`/`levels`/`parents`/`employees` pages plus any `features/*/hooks/*` and page component calling `service.list()` or destructuring `filtered`. Grep `service.list(` and `\.filtered` across `src/` (30+ files); switch each to `items` + the lifted pagination props. **Aggregate/report consumers go to `listAll()` — see §6.**

**Acceptance**
- With `useMockData=true`, every list screen paginates via mock `ListResult` (e.g. 20 seed rows, `perPage=10` → 2 pages); search is debounced, resets to page 1, and cancels in-flight requests; sort toggles re-query (mock sorts the full set, not one page); loading/empty/error intact.
- `npx tsc --noEmit` 0, `eslint` clean, `vitest` green, `next build` exit 0 with no `MISSING_MESSAGE`.

---

### Phase 4 — RBAC enforcement wiring (FE consumes `/me` roles; BE policies + scoping)

**Goal:** navigation/guards driven by the **real** `/me` roles/permissions; backend enforces authorization on every endpoint (defense-in-depth; FE hiding is UX only).

**Frontend**
- Feed the user's **full `roles[]`** (§5.2) into `filterNavigation()` and `requiredRolesForPath()` in `src/lib/constants/navigation.ts` — **no rule changes**, real data instead of `DEMO_USERS`. Resolution is **any-of**: a route shows if **any** held role grants it, so a multi-role user sees the **union** of all their nav.
- **Per-action button gating from `permissions[]`** (G1/D3): read the unioned `permissions[]` from `/me` and **HIDE** (never disable) Add/Edit/Delete buttons, row actions, and bulk actions when the matching `<domain>.<action>` permission is absent (e.g. hide "publish to parent" without `student_exams.publish`; hide a create button without `<domain>.create`). Route role arrays remain the SSoT for whole-page navigation; `RouteGuard` blocks pages whose required role/permission the user lacks.
- **Super-user bypass (M3):** when `roles[]` includes `super-user`, `filterNavigation()` and `RouteGuard` **bypass both the role filter and the `hidden-features` mask** — every module (incl. Finance/Audit/Statistics) is visible and reachable. (Content is no longer in this mask — it is a managed CMS visible to supervisor and super-user; see §5.4/§5.9.)
- **No role switcher and no re-filter-on-switch.** `src/components/layout/sidebar.tsx` computes `filterNavigation(currentUser.roles)` once (memoised on `currentUser.roles`); there is no in-session switch to react to.
- `src/lib/config/hidden-features.ts` stays the **global mask** (applied for all roles **except `super-user`** in `filterNavigation` + `RouteGuard`) regardless of backend roles.

**Backend**
- **Policies** (`app/Policies/*`, registered in `AppServiceProvider`) gating `viewAny/view/create/update/delete` (+ `bulk-delete`) per model, checked via `$this->authorize(...)` (preferred over route middleware, backend §7). **Each action maps to a `<domain>.<action>` permission and `403` is the real boundary** (G1). The seeded **super-user** passes all of them via `Gate::before` (M3) — no explicit grants needed.
- **Data scoping** via query scopes keyed to the **most permissive held role** (M1 — there is no session role): if the user has `supervisor` (or `super-user`) → unrestricted; else if `teacher` → only the user's `circleIds`' students; else if `parent` → only own `childIds`/`parentId`. Never trust frontend filtering.
- Optional `role:`/`permission:` route middleware only for coarse gates; prefer policies.

**Acceptance**
- A teacher sees TEACHING + common nav; a supervisor sees ADMIN; a parent sees the parent portal; a **teacher + supervisor** sees the **union** of both — all driven by `/me` `roles[]`, with no switcher anywhere.
- A teacher (without the supervisor role) calling a supervisor-only endpoint directly → `403` (policy), despite FE hiding.
- A multi-role teacher + supervisor user gets **unrestricted** student data in a single session (most-permissive scope = supervisor), proving **union** scoping (M1), not per-role switching.
- A non-super-user lacking `<domain>.create` sees **no** create button and is `403`'d if it forges the request; the **super-user** sees and can do everything, including the hidden modules.
- `/me` never returns a role outside the 6 known literals (`super-user` + the 5) (M2 test green).

---

### Phase 4.5 — Vertical-slice proof spike (first entity end-to-end, FE wired live)

**Goal:** before the Phase 5 mass build, **prove the entire contract on ONE entity end-to-end** — `nationalities` (a pure lookup table: no scoping, no dependencies, the smallest viable surface). This is the first time the frontend talks to a **live** resource. If anything in §2/§3/§7 is wrong, it surfaces here on one cheap entity instead of across thirty. Nothing else goes live; the rest stays mock.

**Why `nationalities`:** it is an FK target for students/employees (so it is built first anyway), has no data scoping, no composite endpoint, and exercises every contract mechanism (envelope, pagination, sort, camelCase, permission gating, caching) with minimal moving parts.

**Steps**

*Backend (full per-entity build for `nationalities`, per backend §4 + §4.A Definition of Done):*
1. Migration (if missing) with **indexes** on every filtered/sorted column; model with tight `$fillable`/casts; factory + seeder mirroring `bilal/src/lib/dummy/nationalities.ts`.
2. `StoreNationalityRequest`/`UpdateNationalityRequest` (camelCase in, camel→snake mapping **in the Action**, Arabic messages, camelCase 422 keys); `NationalityResource` (camelCase out); `NationalityPolicy` gating `viewAny/view/create/update/delete` on `nationalities.<action>` permissions (seed them).
3. Thin `NationalityController` (`index/show/store/update/destroy` + `bulk-delete` reusing `nationalities.delete`) returning Resources; routes in the `v1` group under `auth:sanctum`.
4. **Caching:** `index`/`show` for this lookup cached via `Cache::remember` under an **explicit namespaced key** (`lookups:nationalities`), **busted on every write** with `Cache::forget('lookups:nationalities')`; send `Cache-Control`/`ETag`. **No `Cache::tags`** (database driver — §7.3).
5. Feature tests for list/show/create/update/delete/bulk-delete + **401/403/404/422**; add the entity to the **OpenAPI** spec (§4.C).

*Frontend (wire it live):*
6. Set `NEXT_PUBLIC_LIVE_RESOURCES=/nationalities` (keeping `NEXT_PUBLIC_USE_MOCK_DATA=true`) so **only** `nationalities` goes http while everything else stays mock (C1).
7. Drive the existing nationalities list screen end-to-end against the live API — no component change (it inherits `createResourceService`).

**Acceptance (proves the contract before Phase 5)**
- The live list returns the **Laravel-default envelope** `{ data, links, meta }`; the FE maps it to `ListResult` and **derives `hasMore`** correctly.
- **Server pagination + sort** work over backend-whitelisted columns (`per_page` honored, cap 100; sort toggles re-query the server, not one page); search is debounced and resets to page 1.
- Resource fields are **camelCase**; `page`/`per_page`/`sort`/`dir`/`search` go on the wire as **snake_case**; 422 `errors` keys are camelCase and map 1:1 to FE fields.
- **401 carve-out** holds: an expired/revoked token on the live `nationalities` request redirects to `/login`; `/auth/*` is never trapped.
- **Per-action permission gating + FE hide:** a user without `nationalities.create`/`.update`/`.delete` sees **no** Add/Edit/Delete control (hidden, not disabled) and is `403`'d if the request is forged; the super-user passes via `Gate::before`.
- **Cutover mechanism proven:** the single entry in `NEXT_PUBLIC_LIVE_RESOURCES` flips just this one entity live with **no component change**; removing it reverts to mock.
- **Caching proven:** a repeat read registers a cache hit (`lookups:nationalities`/`ETag`/ISR); a write **busts** the key (`Cache::forget`) so the change shows immediately — and it works with **no cache tags** (database driver).
- **§4.A Definition of Done met** for `nationalities`: tests green (incl. 401/403/404/422 + FE vitest), permission-gated, eager-loaded/indexed/paginated, OpenAPI updated, lint/format clean, all BE + FE verification gates pass.

---

### Phase 5 — Entity-by-entity endpoint buildout (dependency-first), incremental cutover via `liveResources`

For **each** entity the backend ships, following backend §4: migration (if missing) → model (relationships/scopes/casts/tight `$fillable`) → factory + seeder (mirror `lib/dummy/*`) → `FormRequest`(s) with camel→snake mapping **in the Action** → `Resource` + paginated `Resource::collection($paginator)` → policy → thin controller (`index/show/store/update/destroy` + `bulk-delete`) returning Resources → routes in `v1` → feature tests (list/show/create/update/delete + 401/403/404/422) → **OpenAPI spec updated (§4.C)** → confirm the **§4.A Definition of Done**. **No per-entity frontend change** — `createResourceService` consumes them. Go live **one entity at a time** by adding its resource path to `NEXT_PUBLIC_LIVE_RESOURCES` once its vertical slice is green; the rest stay mock. (The **`nationalities`** slice is already built and proven in **Phase 4.5** — Phase 5 continues from the remaining lookups.)

**Build order**
1. **Reference/lookup tables** (no scoping, FK targets): nationalities, emirates/regions, levels & sections, subjects, buses, drivers. Paths: `/admin/transport/*`, `/admin/levels`, `/admin/subjects`.
2. **Employees** (`/admin/employees`) — `employees-service.ts` already overrides `update`; backend accepts `roles: string[]` validated with `Rule::in($assignableRoles)` — the **4 API-assignable** auth literals (`supervisor`/`teacher`/`parent`/`employee`; the employee form itself offers `{supervisor, teacher, employee}`) (M2; **`super-user` and `student` are omitted from every picker and rejected with `422` if submitted** — M3) **plus** the `Employee.roles` job-classification column (separate data). Teachers are employees with auth role `teacher` (no separate Teachers table — see §11). Job→permission bundles per §5.5.
3. **Students** (`/admin/students`, archive, transfer) + **pending students** approval (`/admin/students/pending`): `POST /students/pending`, `PATCH /students/pending/{id}/approve` (assign `levelId`+`sectionId`, optional `busId`; **enforce `section.levelId === levelId`** server-side; bus optional, region-filtered), `DELETE /students/pending/{id}/reject`. Supervisor-only. **Approval also creates the Parent `User`** with a server-generated hashed password returned once (P1).
4. **Parents** (`/admin/parents`) + parent↔student linkage + password reset (`PATCH /parents/{id}` resets, supervisor-only via `parents.resetPassword`; never parent-editable).
5. **Education/teaching** (session-scoped to teacher circles): memorization, attendance, plans, exams, grades, reports, mushaf/reading. Plus the **aggregate report endpoints** of §6.
6. **Parent portal** (read-only, scoped): `/parent/children` (add sibling → creates pending student via §5/P1, **collecting a parent identifier**), `/parent/attendance`, `/parent/reports`, `/parent/exams` (only `publishToParent=true`), `/parent/notices`, `/parent/reading`.
7. **Messages** + **profile** (all authenticated). Messages: ownership/admin gates on reply/delete. **Profile (PR1/D4 — the backend `PATCH /auth/profile` + `POST /auth/password` endpoints, `UpdateProfileRequest`, and `ChangePasswordRequest` were already built in Phase 1; the FE `/profile` page in Phase 2):** this step only confirms the end-to-end wiring (`GET /auth/me`, `PATCH /auth/profile` self fields only — name, single phone, email, photo; `POST /auth/password` current + new), re-verifies **owner-only** authorization and that **profile edits never touch roles/permissions/branch/status/linkage** — it does **not** build these endpoints anew. The user/employee creation+edit forms (item 2) **exclude `super-user`** (and `student`) from role options and reject them via `Rule::in($assignableRoles)` (M3).
8. **Content CMS** (`/content/*` + the public landing — **IN scope, DB-backed, NOT hidden**, C1 re-scope). Build each area as a real resource with the full per-entity checklist (migration → model → factory/seeder mirroring the FE `lib/dummy/*` → FormRequest(s) with camel→snake mapping in the Action → Resource + paginated collection → Policy → thin controller + `bulk-delete` → routes → feature tests) and its own **content permissions** (C3) granted to **supervisor** (`super-user` passes via `Gate::before`); **no** other role manages content:
   - **landing-sections / homepage CMS** (`landing.view/update`) — hero slider, **OWNER section (the homepage owner block is now DB-backed, not static frontend)**, "why us", FAQ, CTA, footer text.
   - **news & announcements** (`الأخبار`, `/content/news`) — `news.viewAny/view/create/update/delete`.
   - **library** (`المكتبة`, `/content/library`) — `library.viewAny/view/create/update/delete`.
   - **photo album** (`الألبوم`, `/content/library` album) — `album.viewAny/view/create/update/delete`.
   - **site settings** (contact info, social links, footer, basic SEO/site metadata) — `site_settings.view/update`.
   **Public read endpoints are PUBLIC (unauthenticated `GET`)** for the landing sections, news, library, and album so the public pages render live; **write/manage endpoints are content-permission gated** (supervisor + super-user). **Frontend:** remove the `/content` prefix from `HIDDEN_ROUTE_PREFIXES` in `src/lib/config/hidden-features.ts` so the module shows for **supervisor** (super-user already bypasses), and wire the **public landing / news / album / library** pages to consume the **live API** — the existing mock `newsService`/`albumService` (and the library/landing services) go http via `NEXT_PUBLIC_LIVE_RESOURCES`, so admin edits (incl. the owner block) reflect on the public pages immediately.
9. **Roles & Permissions management (super-user)** (`/admin/roles`, R1/§5.11) — the runtime role↔permission **matrix**. **Backend:** `GET /roles` (each editable role + its held permissions), `GET /permissions` (full catalog grouped by domain), `PUT /roles/{role}/permissions` (**sync** + `app(\Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions()`), all gated by a new **`roles.managePermissions`** permission held **only** by super-user; reject `super-user`/`student` as the target `{role}`; the permission **catalog** stays code-seeded — only the **grants** are editable; build `RoleController`/`PermissionController` (or `RolePermissionController`) + `SyncRolePermissionsRequest` + Policy/gate + feature tests. **Frontend:** a **super-user-only** `/admin/roles` matrix page + an **rbac service** + a super-user-only nav item; **re-fetch `/me` on save** (other users update on their next `/me`/login).

**Per-entity acceptance:** live endpoint returns the exact camelCase Resource the FE TS type expects; list returns the Laravel-default paginated envelope; sort/`per_page` honored over whitelisted columns; create/update round-trips through the FE form dialog; bulk-delete is transactional; **each of `index/show/create/update/delete/bulk-delete` is gated by its own `<domain>.<action>` permission + Policy** (G1) and returns `403` for a missing permission or out-of-scope role (most-permissive scope per M1); the FE **hides** the corresponding buttons/pages when the permission is absent; the **super-user** passes every action; user/employee role pickers **exclude `super-user`** and reject it if forged; aggregate pages use `listAll()`; feature tests green. For the **Content CMS** (item 8), additionally: the **public read endpoints** (landing/news/library/album) return live data **without** authentication, the **write/manage endpoints** are content-permission gated (supervisor + super-user), and the public landing/news/album/library pages render from the live API (the homepage owner block is DB-backed). **Performance (§7):** the endpoint **eager-loads** every serialized relation (no N+1) and the migration **indexes** every filtered/sorted/FK column; where the entity backs a multi-service screen, its **composite endpoint** (§7.2) is built; rarely-changing public/lookup data is **cached under explicit namespaced keys + busted on write with `Cache::forget`** (§7.3 — no cache tags, database driver). Every entity additionally satisfies the shared **§4.A Definition of Done** (tests incl. 401/403/404/422 + FE vitest, permission-gated, eager-loaded/indexed/paginated, **OpenAPI §4.C updated**, lint/format clean, all gates green) and passes the **§4.B CI gates** (incl. the coverage floor).

---

### Phase 6 — Cutover & testing

**Goal:** flip remaining entities to live confidently.

- **Cutover:** add each green resource to `NEXT_PUBLIC_LIVE_RESOURCES`; once all entities are listed, set `NEXT_PUBLIC_USE_MOCK_DATA=false` and clear the allowlist. No component changes (centralized seam).
- **Smoke matrix:** for each role (super-user / supervisor / teacher / employee / parent) and for a multi-role employee (e.g. teacher + supervisor) as a **single union session**: login → home (primary by precedence) → each accessible list (pagination + debounced search + server sort) → one create/update/delete + one bulk-delete where permitted → `/profile` edit + password change → logout. Verify the union user sees the **combined** nav/permissions with **no** switcher, the super-user sees everything incl. the still-hidden modules, missing-permission controls are **hidden** (not disabled), and `403` on out-of-scope or missing-permission direct API calls.
- **Content CMS is LIVE (not mock):** supervisor manages `/content/*` (news/library/album/site-settings/landing incl. the owner block) and the **public** landing/news/album/library pages render that live data immediately (public read endpoints). Finance/Audit/Statistics stay mock/empty (super-user sees their live-status unchanged); Content shows live data for supervisor and super-user.
- **Aggregate verification:** every report page (§6) shows totals over the **full** dataset, not page 1.
- **Performance smoke (§7):** no N+1 on any live endpoint (assert via a **query-count** check/telemetry — `Model::shouldBeStrict()` surfaces lazy loads in tests); each multi-service screen issues **one** composite request (§7.2); the **public** landing/news/album/library pages register a **cache hit** (ISR/`Cache-Control`/`ETag`), and a CMS/lookup edit **busts** the cache so the change shows immediately.
- **Refresh/expiry:** refresh restores session via `/me`; an expired/revoked token forces `/login` (and the 401 carve-out never traps `/auth/*`).
- **Gates:** FE `tsc` 0, `eslint` clean, `vitest` green, `next build` exit 0 (no `MISSING_MESSAGE`). BE `php artisan test` green, `pint --test` clean, `migrate:fresh --seed` idempotent.
- **Rollback:** flip `NEXT_PUBLIC_USE_MOCK_DATA=true` (and clear `liveResources`) to restore mock mode with no code change.

---

## 5. Roles & Permissions (detailed and airtight)

### 5.1 The roles (super-user + four + student)

When a user holds several roles the effective scope is the **most permissive** of them (M1, union); the column below is each role's standalone scope. `homeForRole` picks the primary home by precedence **super-user > supervisor > teacher > employee > parent**.

| Auth role (`UserRole`) | Auth method | Standalone scope (M1, union) | Frontend home (`ROLE_HOME`, `src/lib/auth/session.ts`) |
|---|---|---|---|
| **super-user** | email/username + password | **Everything** — bypasses all permission checks **and** the `hidden-features` mask (M3). **Seeded once; never assignable; hidden from every role picker.** | `/dashboard` |
| **supervisor** | email/username + password | Full system (no synthetic parent scope) | `/dashboard` |
| **teacher** | email/username + password | Teaching domain, scoped to assigned `circleIds` | `/education/memorization` |
| **employee** | email/username + password | Non-teaching baseline; per-job permissions (§5.5) | `/dashboard` |
| **parent** | **email OR phone + auto-password** | Read-only, scoped to own children | `/parent/children` |
| **student** *(reserved)* | future | Read-only own data; **no portal** | **see 5.6 — must be blocked, not `/dashboard`** |

`UserRole` (`src/lib/types/user.ts`) = `'super-user' | 'supervisor' | 'teacher' | 'parent' | 'employee' | 'student'` (6 known literals; `super-user` is seed-only and `student` is reserved, leaving **4 API-assignable** auth roles — `supervisor`/`teacher`/`parent`/`employee` — and a staff role **picker** of `{supervisor, teacher, employee}`, since `parent` is created via the student-approval flow, §5.8). Constants (`src/lib/auth/roles.ts`): `ADMIN_ROLES=[supervisor]`, `TEACHING_ROLES=[supervisor,teacher]`, `PARENT_ROLES=[supervisor,parent]`. Navigation constants (`src/lib/constants/navigation.ts`): `ADMIN=[supervisor]`, `TEACHING=[supervisor,teacher]`, `PARENT_ONLY=[parent]`, `ALL_STAFF=[supervisor,teacher,employee]`. **`super-user` is deliberately NOT added to these arrays** — it is handled by an explicit **bypass** in `filterNavigation`/`RouteGuard` (M3) and so sees everything regardless; `homeForRole` adds `super-user → /dashboard`. (All verified against code.)

### 5.2 Multi-role session model — UNION (decision M1 — the central RBAC decision)

- **Backend reality:** `User` (spatie `HasRoles`) can hold multiple roles via `model_has_roles` (backend §7: "Employees may hold multiple roles, e.g. teacher + bus supervisor").
- **Frontend reality:** the session identity historically carried a **single** `AppUser.role`; navigation, `RouteGuard`, `ROLE_HOME` are taught to read the **full** `roles[]` instead (a single derived `role` is kept only as a legacy convenience).
- **Resolution (pinned — UNION, no switching):** `/me` returns `roles: UserRole[]` + `permissions: string[]`. A user holding multiple roles gets the **combined** navigation + permissions **at once, in a single session**.
  - **No active-role selection at login, no in-session role switcher, and no `X-Session-Role` header.** Login simply sets the session from `/me`'s `roles[]`/`permissions[]`.
  - **Effective permissions = the union of every held role** (`getAllPermissions()` already unions on the backend). The FE reads the unioned `permissions[]` for per-action button gating (§5.10).
  - **Navigation/route resolution is any-of:** `filterNavigation(roles)` and `requiredRolesForPath` show a route if **any** held role grants it. `homeForRole(roles)` returns the primary home by precedence **super-user > supervisor > teacher > employee > parent**.
  - **Data scoping uses the most permissive held role:** if the user has `supervisor` (or `super-user`) → unrestricted; else if `teacher` → own circles; else if `parent` → own children. Computed server-side from `getRoleNames()`; never trust the frontend.
- **Backward compatibility:** `roles?` is optional; single-role mock `DEMO_USERS` keep working (logic falls back to `[role]`). The backend is the source of truth — the FE union is mirrored from `/me` and always re-verified by policies (`403` is the boundary).
- **Contract note:** there is **no `X-Session-Role` header** anywhere; the multi-role **union** model is the term added to both `CLAUDE.md` files.

### 5.3 `EmployeeRole` (job) vs `UserRole` (auth) — decision M2

These are **different enums** and were conflated in the draft:
- `EmployeeRole` (`src/lib/types/employee.ts`, **job classification, data**): `teacher | secretary | accountant | supervisor | coordinator | driver`.
- `UserRole` (auth): `super-user | supervisor | teacher | parent | employee | student` — **6 known literals**.

**Rule:** Spatie **auth-role names are constrained to the 6 known `UserRole` literals**, of which **only 4 are API-assignable** (`supervisor`, `teacher`, `parent`, `employee`): `super-user` is **seed-only** (M3 — created once by the seeder, never granted through any endpoint, omitted from every role picker, rejected with `422` via `Rule::in($assignableRoles)` if submitted) and `student` is reserved. The staff role picker on the employee/user form offers only `{supervisor, teacher, employee}`; a `parent` user is provisioned through the student-approval flow (§5.8/P1), not the employee form. `Employee.roles` stays a **separate data column** (job metadata). Provisioning maps job → auth role:

| Job (`EmployeeRole`) | Auth role (Spatie) | Notes |
|---|---|---|
| `teacher` | `teacher` | + circle scope from the teacher/employee assignment |
| `supervisor` | `supervisor` | full admin |
| `secretary`, `accountant`, `coordinator`, `driver` | `employee` | per-job capability via **permissions** (§5.5), not extra roles |

A multi-role staff member is a user holding multiple **auth** roles (e.g. `teacher` + `supervisor`) — resolved as a **union** per §5.2. A feature test asserts `/me` never emits a role outside the 6 known literals.

### 5.4 Role → route/section matrix (corrected against code)

Derived from `navigation.ts` (`requiredRolesForPath` longest-prefix of section ∩ item, then `ROUTE_ROLE_OVERRIDES` if more specific; `PUBLIC_DASHBOARD_PREFIXES=['/messages','/profile']` bypass role checks). Corrections vs the draft are flagged.

| Section / route prefix | Effective access | Source / note |
|---|---|---|
| **(every section, incl. hidden)** | **super-user** | M3 bypass — `super-user` skips both the role filter **and** the `hidden-features` mask, so **all** sections (incl. `/finance`, `/admin/audit`, `/education/statistics`) are visible and reachable. It is the only identity for which the hidden mask does not apply. (`/content/*` is no longer in the mask — it is a managed CMS visible to supervisor too.) |
| `/dashboard` | supervisor, teacher, employee (`ALL_STAFF`) | nav item |
| `/messages`, `/profile`, **and `/messages/notices`** | **any authenticated** | `PUBLIC_DASHBOARD_PREFIXES` short-circuits → `requiredRolesForPath` returns `null`. **Correction:** the nav item `/messages/notices` *shows* only to TEACHING, but the **route is ungated** for any authenticated user. Backend must treat it as authenticated-only, **not** TEACHING. |
| `/settings` | supervisor (`ADMIN`) | nav item |
| `/admin/roles` | **super-user ONLY** | Runtime role↔permission **matrix** (§5.11); gated by `roles.managePermissions`; nav item shown **only** to super-user; `RouteGuard` blocks every other role (supervisor included). |
| `/parent/*` | parent (`PARENT_ONLY`) | section |
| `/admin/students*`, `/admin/teachers/students`, `/admin/parents`, `/admin/employees*` | supervisor (`ADMIN`) | section |
| `/admin/students/cards` | supervisor (`ADMIN`) | **`ROUTE_ROLE_OVERRIDES`** (was missing in draft) |
| Education `/education/mushaf|reading|memorization|attendance|plans|exams|grades|exams/level-report|reports` | supervisor, teacher (`TEACHING`) | items |
| Education admin-only: `/admin/levels`, `/admin/subjects*`, `/education/exams/archive`, `/education/teacher-log` | supervisor (`ADMIN`) | items |
| `/education/reports` | supervisor, teacher (`TEACHING`) | override |
| **`/education/statistics`** | **net HIDDEN for all roles** | `ROUTE_ROLE_OVERRIDES` = TEACHING **and** `HIDDEN_ROUTE_PREFIXES` includes `/education/statistics` → hidden mask wins. **Correction:** treat as feature-flag-off, not TEACHING. |
| Transport `/admin/transport/*` | supervisor (`ADMIN`) | section |
| Administration `/admin/administration/*`, `/admin/audit` | supervisor (`ADMIN`); `/admin/audit` **also HIDDEN** | section + hidden mask |
| `/content/*` | supervisor (`ADMIN`) + super-user — **managed CMS, NOT hidden** | content-permission gated; public read endpoints (landing/news/library/album) are public; the homepage owner block is DB-backed (C1). |
| `/finance/*` | supervisor (`ADMIN`) **but HIDDEN** | section + hidden mask |

**Backend mirroring:** replicate the precedence (longest-prefix item, then more-specific override, then public bypass) **or** — cleaner — drive both FE nav and BE gates from the §5.4 permission catalog. Do **not** propagate the `/messages/notices` quirk: backend gates it as authenticated-only.

### 5.5 Backend permission catalog (`<domain>.<action>`)

Seed via `PermissionSeeder`; attach to roles. `S`=supervisor, `T`=teacher (scoped to circles), `E`=employee (baseline + per-job permissions), `P`=parent (scoped to children). **`super-user` is intentionally absent from the columns below: it needs no explicit grants — `Gate::before` (M3) makes it pass every permission/policy check, so it implicitly holds all permissions in this catalog and any future ones.**

| Domain | Permissions | S | T | E | P |
|---|---|:--:|:--:|:--:|:--:|
| users | `users.viewAny/view/create/update/delete` | ✅ | — | — | — |
| roles *(super-user only)* | `roles.managePermissions` (runtime role↔permission editing — §5.11) | — | — | — | — |
| employees | `employees.viewAny/view/create/update/delete`, `employees.assignRoles` | ✅ | — | per-job | — |
| students | `students.viewAny/view/create/update/delete/transfer/archive` | ✅ | view (own circle) | per-job | view (own children) |
| students.pending | `students.pending.viewAny/approve/reject` | ✅ | — | — | create (sibling) |
| parents | `parents.viewAny/view/create/update`, `parents.resetPassword` | ✅ | — | — | — |
| levels/sections | `levels.viewAny/manage`, `sections.manage` | ✅ | view | view | — |
| subjects | `subjects.manage`, `subjects.assignTeachers` | ✅ | view | — | — |
| memorization | `memorization.view/record` | ✅ | ✅ (own circle) | — | view (own child) |
| attendance | `attendance.view/record` | ✅ | ✅ (own circle) | — | view (own child) |
| plans | `plans.view/manage` | ✅ | ✅ | — | — |
| exams | `exams.view/manage` | ✅ | ✅ (own circle) | — | — |
| grades | `grades.view/record` | ✅ | ✅ (own circle) | — | — |
| student_exams | `student_exams.view`, `student_exams.publish` | ✅ | ✅ | — | view (published only) |
| reports | `reports.view` | ✅ | ✅ (own circle) | — | view (own child) |
| transport | `transport.viewAny/manage` (nationalities/regions/buses/drivers) | ✅ | — | per-job | — |
| administration *(4 CRUD resources)* | `evaluations.viewAny/view/create/update/delete`, `warnings.*`, `certificates.*`, `letters.*` (each a full CRUD resource under `/admin/administration/*`, supervisor-visible) | ✅ | — | — | — |
| messages | `messages.view/send`, `messages.delete` (own/admin) | ✅ | ✅ | ✅ | ✅ |
| audit *(hidden)* | `audit.view` | ✅ | — | — | — |
| finance *(hidden)* | `finance.*` | ✅ | — | — | — |
| content — news | `news.viewAny/view/create/update/delete` | ✅ | — | — | — |
| content — library | `library.viewAny/view/create/update/delete` | ✅ | — | — | — |
| content — album | `album.viewAny/view/create/update/delete` | ✅ | — | — | — |
| content — site settings | `site_settings.view/update` | ✅ | — | — | — |
| content — landing | `landing.view/update` | ✅ | — | — | — |

> **Content is a managed CMS (C1 re-scope), NOT hidden.** The `news`/`library`/`album`/`site_settings`/`landing` write/manage permissions above are granted to **supervisor** (and the **super-user** holds them via `Gate::before`); no other role manages content. The **public read** side of these resources (the landing sections, news, library, and album shown on the public landing/site) is served by **unauthenticated `GET`** endpoints that require **no permission** — only the write/manage endpoints are gated. The homepage owner block is part of the landing CMS and is therefore DB-backed.

**Employee per-job permission bundles** (recommended starting sets; granted as Spatie **permissions** on the user, not extra roles):

| Job | Permission bundle (starter) |
|---|---|
| secretary | `students.viewAny/view`, `students.pending.viewAny`, `parents.viewAny/view`, `reports.view`, `messages.*` |
| accountant | `reports.view`, `messages.*` (+ `finance.*` only when finance is re-exposed) |
| coordinator | `students.viewAny/view`, `attendance.view`, `transport.viewAny`, `messages.*` |
| driver | `transport.viewAny` (own bus roster), `students.view` (own bus), `messages.*` |

These bundles surface in `/me` `permissions[]` and drive **button-level** gating; navigation for all of them is the `employee` auth role's nav.

### 5.6 Student home — latent redirect-loop fix

`ROLE_HOME.student = '/dashboard'` (`session.ts` line 26), but `/dashboard` is `ALL_STAFF` (excludes `student`). If a student token is ever issued, `RouteGuard` denies `/dashboard` → redirects to `homeForRole('student')='/dashboard'` → denies again → **infinite loop**. **Fix before any student token exists:** either point `ROLE_HOME.student` at a dedicated (future) student landing, or have `RouteGuard` short-circuit the `student` role to a "no portal yet" screen / `/login`. Until the student portal ships, the backend exposes **no** student endpoints and issues **no** student tokens.

### 5.7 spatie seeding mechanics

- `RoleSeeder` → `Role::firstOrCreate(['name'=>$n,'guard_name'=>'web'])` for **`super-user`** + the four (+ `student` reserved). Idempotent. `super-user` is created here but is **seed-only** — never granted via the API/UI.
- `PermissionSeeder` → `Permission::firstOrCreate(...)` for every catalog entry, then `Role::findByName($r)->givePermissionTo([...])`. Idempotent. **No grants are attached to `super-user`** — it passes everything via `Gate::before` (M3).
- `DatabaseSeeder` → roles → permissions → demo users + `assignRole` (mirror FE `DEMO_USERS`) **plus exactly one `super-user` account** (`firstOrCreate`, so re-seeding never creates a second). After seeding, `forgetCachedPermissions()` (spatie 24h cache).

### 5.8 Auto-password / parent credential (decision P1)

- **Generation:** **server-side at approval** (`ApprovePendingStudentAction`), cryptographic (`Str::password`/`Str::random`), **hashed on store**, **returned once** for display. The "store as-is" option is **deleted**.
- **Credential owner:** a **Parent `User`** (`user_type=parent`) is created/linked at approval (not at registration). The auto-password belongs to that Parent user, **not** the Student record.
- **Identifier:** parent logs in with **email OR phone** (registration makes email optional — `registration-form.tsx` submits `form.email.trim() || undefined`). Approval **must collect/confirm a parent identifier** (email or phone) so email-less parents can authenticate.
- **Frontend:** `src/features/registration/components/registration-form.tsx` switches `pendingStudentsService.create()` → real `POST /students/pending` (or `authService.register`), **stops** calling `generateAutoPassword()` (remove the `import { generateAutoPassword } from '@/lib/types/student'` usage), and sends **no** password. `POST /students/pending` returns nothing secret. Public registration is rate-limited and validated server-side.
- **Reset:** supervisors only (`parents.resetPassword` via `PATCH /parents/{id}`); never parent-editable.
- **`CLAUDE.md` contradiction to resolve:** backend §7 ("frontend issues on approval") vs §6 ("Action issues parent password"). **Pinned: the server Action issues and hashes it at approval.** Update both docs to say so.

### 5.9 Keeping `hidden-features` enforced

`HIDDEN_ROUTE_PREFIXES` (`src/lib/config/hidden-features.ts`) is the **global feature-flag mask**, applied in `filterNavigation` and `RouteGuard` for **every** role before role filtering. Verified hidden: `/finance`, `/admin/audit`, `/education/statistics`, `/admin/circles`, `/settings/circles`, and `/admin/teachers` (**`exact` — `/admin/teachers/students` stays reachable**). Adding a feature to `navigation.ts` roles does nothing if its prefix is hidden; re-expose by removing the prefix here. Backend treats "hidden" as feature-flag-off, **not** a permission boundary. **`/content` is NO LONGER in this mask (C1 re-scope):** the FE task removes the `/content` prefix from `HIDDEN_ROUTE_PREFIXES` so the managed Content CMS shows for **supervisor** (super-user already bypasses); it is now a real, content-permission-gated module, not feature-flag-off.

**Super-user exception (M3):** the seeded `super-user` **bypasses the hidden-features mask entirely** — `filterNavigation`/`RouteGuard` short-circuit for it, so Finance/Audit/Statistics and every other masked module are visible and reachable. This is the **only** identity that sees hidden modules; the mask still applies to all other roles (including supervisor). (Content is no longer masked, so supervisor sees it too — see C1.)

### 5.10 Per-action gating contract (decision G1/D3 — applies to every resource)

**Authorization is per action, everywhere.** Each `index/show/create/update/delete` (+ `bulk-delete`) endpoint is gated by a `<domain>.<action>` permission **and** a Policy on the backend — the `403` is the real security boundary. The mapping is fixed:

| Frontend control | Backend endpoint | Required permission |
|---|---|---|
| List page / table | `GET /<resource>` (index) | `<domain>.viewAny` |
| Detail view | `GET /<resource>/{id}` (show) | `<domain>.view` |
| **Add** button / create dialog | `POST /<resource>` (store) | `<domain>.create` |
| **Edit** button / row action | `PUT /<resource>/{id}` (update) | `<domain>.update` |
| **Delete** / **bulk-delete** | `DELETE /<resource>/{id}`, `POST /<resource>/bulk-delete` | `<domain>.delete` |

- **Frontend reads the unioned `permissions[]` from `/me`** and uses it to decide visibility: it **HIDES** the Add/Edit/Delete buttons and the row/bulk actions, and **blocks whole pages via `RouteGuard`**, when the matching permission is absent.
- **D5 — a missing permission HIDES the control; it never disables it.** Do not render a greyed-out/disabled button for a permission the user lacks — omit it entirely (pages are blocked, buttons are removed).
- **Custom / non-CRUD actions are gated identically.** The five CRUD verbs in the table above are not the whole surface: **every** custom action also maps to its own `<domain>.<action>` permission + Policy method on the backend and is **HIDDEN** on the FE when absent — e.g. `students.transfer`, `students.archive`, `students.yearTransfer` / `students.yearTransferUndo` (نقل الطلاب بين الأعوام الدراسية + undo — granted to `supervisor`, see `YearTransferPermissionSeeder`), `students.pending.approve`, `students.pending.reject`, `parents.resetPassword`, `student_exams.publish`, `subjects.assignTeachers`, `employees.assignRoles`, `memorization.record`, `attendance.record`, `grades.record`, `administration.evaluations/warnings/certificates/letters`. **`bulk-delete` is the one exception — it does not get a dedicated permission; it reuses `<domain>.delete`.**
- The **super-user** is exempt from all of this (M3): every control shows and every action passes via `Gate::before`.
- The permission catalog is §5.5; every new resource MUST add its `<domain>.<action>` permissions (CRUD **and** custom) to the seeder **and** a Policy (backend §13).

### 5.11 Runtime role↔permission management — super-admin only (R1)

The default role→permission grants are seeded (§5.5/§5.7), but the **super-user** can adjust them **at runtime** from a dedicated admin page (`/admin/roles`) — a **roles × permissions matrix** that toggles which permissions each role holds. This makes RBAC grants **editable in production** without a redeploy: the seeder provides the defaults; the page tunes them.

- **What is editable vs fixed.** The **permission catalog** — the universe of `<domain>.<action>` strings (§5.5) — stays **code-defined and seeded**; the page does **not** create or delete permissions. Only the **grants** (role → permission) are editable. The editable roles are **`supervisor`, `teacher`, `employee`, `parent`** (`student` is reserved and never shown). The **`super-user` role is NEVER editable or assignable** — it is omitted from the matrix entirely and rejected if targeted (it already passes everything via `Gate::before`, M3).
- **Gating.** The whole surface is gated by a new permission **`roles.managePermissions`** held **only** by `super-user` (it is added to the catalog but granted to **no** seeded role — `super-user` reaches it via `Gate::before`). A non-super-user calling these endpoints → `403`.
- **Endpoints** (all under `auth:sanctum` + the `roles.managePermissions` gate):
  - `GET /api/v1/roles` — each role plus its currently-held permissions.
  - `GET /api/v1/permissions` — the full catalog, grouped by domain (the matrix columns).
  - `PUT /api/v1/roles/{role}/permissions` — **sync** the role's permissions to the submitted set. Rejects `super-user` (and `student`) as the `{role}`. After any change, call `app(\Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions()` so the spatie cache reflects the new grants immediately.
- **Backend artifacts.** `RoleController` + `PermissionController` (or a single `RolePermissionController`) — thin, returning Resources; a `SyncRolePermissionsRequest` validating the submitted permission names against the **catalog** and the `{role}` against the editable set (`Rule::in(['supervisor','teacher','employee','parent'])`, never `super-user`/`student`); a Policy / `roles.managePermissions` gate; feature tests (matrix read, sync grants, `403` for a non-super-user, reject editing `super-user`, cache forgotten after write).
- **Frontend.** A new **super-user-only** page `src/app/(dashboard)/admin/roles/page.tsx` (guarded by `RouteGuard` + a nav item visible **only** to `super-user`), an **rbac service** (`roles-service`/`permissions-service`, or one `rbac-service`) hitting the three endpoints, and a **matrix UI** (roles as rows, permissions grouped by domain as columns, one toggle per cell). After a successful save the page **re-fetches `/me`** so the acting session picks up any change to its own effective permissions; **other users see the change on their next `/me`/login** (a note in the UI makes this propagation explicit).
- **Caveat.** Because grants are now runtime-mutable, the seeder is the **default**, not the runtime source of truth; never assume a role's grants from the seeder alone — read them from `GET /api/v1/roles`.

### 5.12 Sidebar switches — `menu.*` (what the matrix shows is what the sidebar renders)

Data permissions (`levels.viewAny`, `buses.viewAny`, …) are held by roles for **API reasons** as well as to see a page — a teacher holds `nationalities.viewAny` to fill a dropdown, a parent holds `attendance.view` for the portal — and several pages share one (`employees.viewAny` opens employees, its archive and the work report). So they cannot say which **sidebar link** a role should see. Per-link role lists hard-coded in the frontend used to decide that, which a permission ticked in the matrix could neither override nor explain.

- **One switch per link.** Every matrix-controlled sidebar link has its own permission in the **`menu`** domain (`menu.levels`, `menu.buses`, `menu.employeeArchive`, …; the full list is `src/lib/constants/navigation.ts`, each item's `menu:` field). They are ordinary Spatie permissions: they appear in `GET /permissions` under the `menu` domain (shown first in the matrix, labelled «القائمة الجانبية»), are granted per role through `PUT /roles/{role}/permissions` and arrive in `/auth/me`'s unioned `permissions[]`. **No backend code reads them** — the API keeps authorizing on the data permissions; the switches only decide what the frontend shows.
- **The rule.** A link is visible ⇔ the user holds its `menu.*` switch **and** the page's own data permission (`requires:`), unioned over all held roles. There are no per-role lists any more. Fixed links are the exception, gated by role: the dashboard (every staff role), the parent portal (`parent`), Settings and the roles page (`super-user`). `super-user` bypasses everything, including the hidden-features mask.
- **The matrix keeps the pair in step.** Switching a link on also grants its data permission; revoking the data permission also switches the link off. Switches for links hidden out-of-spec are seeded but not shown.
- **Seeding.** Migration `2026_10_15_120000_seed_sidebar_menu_permissions.php` (SQL twin: `database/changes.sql`, 2026-10-15) creates the 50 switches and gives each role the links it already had; `bus-supervisor` is seeded with الرسائل + تنبيهاتي only. **A new sidebar link needs its switch seeded the same way** (add it to that migration's list for fresh installs and append an idempotent block to `changes.sql`), otherwise nobody but the super-user will see it.
- **Freshness.** The frontend re-reads `/auth/me` when the tab regains focus and every minute while visible (`SESSION_REFRESH` in `app-config.ts`), so an edit made here reaches users who are already logged in without a re-login. The Spatie permission cache must still be flushed after any direct SQL (`DELETE FROM cache WHERE \`key\` LIKE '%spatie.permission.cache%'`).

---

## 6. Aggregate / report data path (the highest-impact server-pagination hazard)

Once `list()` is default-paginated (Phase 3), any consumer that aggregates over the **full** dataset would silently compute totals over **page 1 only**. Frontend `CLAUDE.md` §2 explicitly calls report pages a special case. **Plan:**

- **`listAll(query?)`** is added to `ResourceService` (Phase 3): mock returns the full filtered (unsliced) collection; http calls a **dedicated report endpoint** or `?per_page=all` (backend maps `all` to no pagination, capped to a safe maximum). For large datasets, prefer **server-computed aggregate endpoints** (`/reports/*`) that return the rolled-up figures rather than shipping every row.
- **Consumers that MUST use `listAll()` / a report endpoint (not `list()`):**
  - `src/features/memorization/hooks/use-monthly-report.ts`
  - `src/features/students/hooks/use-students-report.ts`
  - `src/features/employees/hooks/use-teacher-work-report.ts`
  - `src/app/(dashboard)/education/reports/*` (daily, circles, students, attendance)
  - `/education/exams/level-report`
  - Parent reports (`/parent/reports`)
  - Finance reports (when re-exposed)
- **Rule:** paginated **lists/tables** use `list()`; **reports/aggregates/exports** use `listAll()` or a `/reports/*` endpoint. A report page must never derive totals from a paginated `list()` result. Add a lint/PR-review note to enforce this.

---

## 7. Performance & caching (cross-cutting discipline — applies to every endpoint & screen)

Performance is a first-class requirement, not an afterthought. Both repos enforce the rules below (backend `CLAUDE.md` §14 is the authoritative companion). Three pillars: **query optimization**, **one request per screen (composite endpoints)**, and **caching rarely-changing data**.

### 7.1 Query optimization (every endpoint)

- **No N+1 — ever.** Eager-load (`with(...)`) **every** relation an endpoint serializes; enable `Model::shouldBeStrict()` in non-prod so a stray lazy load throws during development/tests.
- **Select only needed columns** (`select(...)` / constrained eager loads); never ship every column when a Resource uses a handful.
- **Index every filtered/sorted/FK column** in the migration (`index()` / composite indexes over the documented filter params, every `sort`-whitelist column, and every `foreignId`).
- **Paginate every list** (default `per_page=25`, capped `100`); the **only** unbounded path is the deliberate `listAll()`/report path (§6).
- **DB transactions** wrap every multi-step write; **queue** slow side-effects (mail, notifications, heavy exports) instead of blocking the request.

### 7.2 One request per screen — composite / "screen" endpoints

When a page needs several models, return them in **one** endpoint instead of N round-trips. Several frontend screens currently fan out across many services on mount; each gets a **composite endpoint** (one well-shaped response, or an `?include=` expansion) so the FE makes **one** aggregate call per screen when live. The route names below are **canonical** — both repos use exactly these paths (the `*-data` convention for bundled form lookups):

| Screen (FE) | Composite endpoint | Bundles |
|---|---|---|
| Student form (students page + `student-form-dialog`, ~7 services today) | `GET /students/form-data` | nationalities + emirates + regions + levels + sections + buses |
| Pending-student approval | `GET /students/pending/form-data` | levels + sections + buses (region-filtered) |
| Memorization session | `GET /memorization/session?circleId=&date=` | circle(s) + students + sessions + group achievements for that circle+date |
| Buses form | `GET /buses/form-data` | emirates + regions + drivers (`role=driver`) + supervisors (`role=supervisor`) |
| Levels & sections | `GET /levels/with-sections` (or `?include=sections,teachers`) | levels + sections + teachers (for `primaryTeacher`) |
| Dashboard | `GET /dashboard/statistics` | role-scoped aggregate counts with gender split (one endpoint) |

Prefer one well-shaped response (or `?include=`) over the FE firing parallel calls. **When live, the FE calls the one aggregate endpoint per screen** rather than the historical N parallel service calls.

### 7.3 Caching rarely-changing data

- **No Redis on this server.** Cache **and** queue both use the **`database`** store (`CACHE_STORE=database`, `QUEUE_CONNECTION=database`). The database (and file) cache driver does **NOT** support cache tags — so **never** use `Cache::tags(...)`/tagged keys; use **explicit, namespaced keys** instead.
- **Cache** public/CMS content (homepage/landing incl. the **owner** block, news, album, library, site-settings — §5.5, Phase 5 item 8) and **lookup tables** (nationalities, emirates, regions) via `Cache::remember` under **explicit namespaced keys** (e.g. `content:home`, `content:news`, `lookups:nationalities`, `lookups:emirates`).
- **Bust on write:** every CMS/lookup create/update/delete calls **`Cache::forget('<key>')`** on the affected key(s) so edits show immediately (no stale public page). If a logical group must be cleared together, keep a **small key-registry** (a list of member keys under one index key) and forget each member — there are no tags to clear a group in one call.
- **HTTP caching:** public read endpoints send `Cache-Control`/`ETag` so the FE (**Next.js ISR / route cache**) caches them; the public landing/news/album/library pages use ISR.
- **NEVER cache** per-user/role-scoped data or frequently-changing lists (students, attendance, memorization, messages, reports).
- **Cache store is config-driven** and pinned to **`database`** (no Redis here); chosen via `config()`, never hardcoded.

> Note: without Redis there are **no cache tags** — group invalidation is done by forgetting each explicit key (optionally via a key-registry), not `Cache::tags()->flush()`.

This section is referenced by **Phase 5 per-entity acceptance** (every endpoint eager-loaded + every filtered/sorted/FK column indexed; composite endpoints built; public CMS + lookups cached under explicit keys + busted on write with `Cache::forget`) and **Phase 6** (perf smoke: no N+1 via a query-count assertion, public pages cache-hit).

---

## 8. End-to-end auth flow (sequence)

```
LOGIN
  FE login page → authService.login(login, password)
     → POST /api/v1/auth/login { login, password }                     (throttle:login)
  BE LoginRequest validates (camelCase error keys) → AuthenticateUserAction
     → resolve by email|username|phone → Hash::check → reject archived
     → createToken(device, abilities)
     → 200 { data:{ token, expiresAt, user{ roles[], permissions[](union), scope fields } } }
  FE: store token (localStorage[env.authTokenKey]) + setSession({ user, token })
     → NO role selection (multi-role = UNION, M1): session = ALL roles[] at once
     → router.push(homeForRole(roles))          // primary by precedence:
                                                 // super-user > supervisor > teacher > employee > parent

SUBSEQUENT REQUESTS
  service.list/get/create/... → http-client injects Authorization: Bearer   (NO X-Session-Role)
     → BE auth:sanctum resolves user
     → Gate::before: hasRole('super-user') ? ALLOW-ALL (skips permission + hidden checks)
     → else Policy checks <domain>.<action> permission (UNION of all roles) + DATA SCOPE (most permissive role)
     → Resource / paginated collection → FE unwraps { data } (single) or { data, links, meta } (list)
        (FE has already HIDDEN any control whose permission is absent — UX mirror of the 403 boundary, D5)

401 (token attached AND path not /auth/*)
  BE 401 { message } → FE http-client → clearAuth() → redirect /login
     (ErrorState, if rendered, shows "session expired", hides retry)
401/422 on /auth/login or /auth/forgot-password
  → NOT intercepted → propagates to the page for inline Arabic error display

REFRESH / PERSISTENCE
  App load (skip when useMockData) → token in localStorage?
     → authService.me() → GET /api/v1/auth/me
        → 200 repopulate currentUser (full roles[] + unioned permissions[]) ; 401 → clearAuth() → /login
  (isBootstrapping gates render; RouteGuard does not redirect while bootstrapping)

PROFILE (any authenticated user, owner-only — PR1/D4)
  /profile → authService.updateProfile({ name, phone, email, photo }) → PATCH /api/v1/auth/profile
           → authService.changePassword(current, next)                    → POST  /api/v1/auth/password
  BE authorizes owner-only; edits NEVER change roles/permissions/branch/status/linkage → 200 updated user

LOGOUT
  Topbar onClick → authService.logout() → POST /api/v1/auth/logout (Bearer)
  BE currentAccessToken().delete() → 204
  FE clearAuth() + redirect /login ; on network failure still clearAuth() (fallback)
  Optional: storage event → sync logout across tabs
```

---

## 9. Concrete frontend change list (real files)

| File | Change |
|---|---|
| `src/lib/config/env.ts` | Add `authTokenKey` + `liveResources` (parsed `string[]`); keep typed-access discipline. |
| `src/lib/config/app-config.ts` | Add `SEARCH_DEBOUNCE_MS`, single default `PAGE_SIZE` (= backend 25). |
| `src/store/use-app-store.ts` | Add `token`/`isAuthenticated`/`isBootstrapping`; `setSession`/`clearAuth` (**no `setSessionRole`** — union, not switching); persist+restore token; extend `AppUser` with full `roles[]` + unioned `permissions[]` (derived primary `role` kept for legacy); mock demo buttons set sentinel token; `DEMO_USERS` mock-only. |
| `src/lib/auth/auth-service.ts` *(new)* | `login`/`logout`/`me`/`forgotPassword` + **`updateProfile` (`PATCH /auth/profile`)** + **`changePassword` (`POST /auth/password`)** vs `/auth/*`; always live; returns unwrapped payloads. |
| `src/lib/auth/use-auth.ts` *(new)* | Bootstrap `me()` (skip in mock), gate on `isBootstrapping`, optional cross-tab `storage` listener. |
| `src/lib/api/http-client.ts` | Inject `Authorization: Bearer` (**no `X-Session-Role`**); `getList` returns `{ items, meta }`; unwrap `.data` for single; **401 carve-out** (token attached AND not `/auth/*`); keep `ApiError`/`buildUrl`/version. |
| `src/lib/services/base/resource-service.ts` | Add `ListResult<T>` + `listAll()`; extend `ListQuery` (`page`,`perPage`,`sort`,`dir`); `list()`→`Promise<ListResult<T>>`; paginate+sort in mock & http; map snake_case params/meta; `removeMany`→`bulk-delete`; per-resource live override via `liveResources`. |
| `src/features/shared/hooks/use-resource-list.ts` | Lift `page`/`pageSize`/`sort`/`dir`/`total`/`hasMore`; debounce search; thread `AbortSignal`; deps incl. search/page/sort/dir; reset page on search/sort; drop `filtered`; ignore `AbortError`; 401 to global handler. |
| `src/components/shared/data-table.tsx` | Fully controlled: `total`,`currentPage`,`onPageChange`,`pageSize`,`sort`,`dir`,`onSortChange`; remove internal page/slice/`localeCompare` sort; select-all = current page + optional "select all matching". |
| `src/components/shared/pagination.tsx` | Derive `totalPages` from server `total`; API unchanged. |
| `src/components/shared/error-state.tsx` | Optional `status`; 401 "session expired/login required", hide retry. |
| `src/app/(auth)/login/page.tsx` | Real `authService.login()`; 401/422/network mapping; **no role selection** — set session from `roles[]`, then `homeForRole(roles)` (primary by precedence). |
| `src/app/(auth)/forgot-password/page.tsx` | Real `authService.forgotPassword()`; generic success. |
| `src/app/(dashboard)/profile/page.tsx` *(new)* | Self-service profile (PR1/D4): own-data form (name/phone/email/photo) + change-password (current+new); owner-only; **no role/permission/branch/status/linkage controls**; Arabic `profile` namespace. |
| `src/app/(dashboard)/admin/roles/page.tsx` *(new)* | **Super-user-only** role↔permission **matrix** (R1/§5.11): roles × permissions toggles via the rbac service; on save **re-fetch `/me`** so the acting session picks up its own changes (other users update on their next `/me`/login); guarded by `RouteGuard` + a super-user-only nav item; loading/empty/error states; Arabic copy. |
| `src/lib/services/rbac-service.ts` *(new)* | `roles-service`/`permissions-service` (or one `rbac-service`) hitting `GET /roles`, `GET /permissions`, `PUT /roles/{role}/permissions`; always live; super-user-only (R1/§5.11). |
| `src/components/layout/topbar.tsx` | Logout `onClick` → API + `clearAuth` + redirect; loading + fallback; keep mobile styling. **No role switcher** (union session). |
| `src/components/layout/route-guard.tsx` | Bootstrapping gate → auth check → role check **against the full `roles[]` (any-of)**; **`super-user` bypasses both the role check and the `hidden-features` mask** (M3); keep `isHiddenRoute`/`requiredRolesForPath`; mock sentinel token honored. |
| `src/components/layout/sidebar.tsx` | `useMemo(filterNavigation(roles), [roles])` (any-of union); **`super-user` renders the full tree incl. hidden modules**; no role-switch handling. |
| `src/lib/constants/navigation.ts` | `filterNavigation`/`requiredRolesForPath` resolve against the full `roles[]` (any-of); **`super-user` short-circuits to the full nav and bypasses the `hidden-features` mask** (M3). **Add `/admin/roles` as a nav item visible ONLY to `super-user`** (R1/§5.11). |
| `src/lib/config/hidden-features.ts` | **Remove the `/content` prefix from `HIDDEN_ROUTE_PREFIXES`** (C1 re-scope) so the managed Content CMS shows for **supervisor** (super-user already bypasses). Finance/Audit/Statistics/legacy-circles stay masked. |
| `src/app/content/*` + public landing pages (incl. the homepage owner block) | Wire the **public** landing/news/album/library pages to the **live API** (public read endpoints) so admin edits render immediately; the homepage owner section becomes **DB-backed** via the landing CMS (no longer static frontend). |
| `src/lib/services/news-service.ts`, `album-service.ts` (+ library/landing/site-settings services) + `NEXT_PUBLIC_LIVE_RESOURCES` | Flip the existing mock `newsService`/`albumService` (and the library/landing/site-settings services) to **http** via `liveResources`; add the content resource paths to the live allowlist when each slice is green. |
| `src/app/(dashboard)/layout.tsx` | Mount `use-auth` bootstrap; gate on `isBootstrapping`. |
| `src/features/registration/components/registration-form.tsx` | Real `POST /students/pending`; remove `generateAutoPassword` usage; send no password. |
| `src/features/employees/components/employee-form-dialog.tsx` | Role picker offers only the staff-assignable auth roles **`{supervisor, teacher, employee}`** — **excludes `super-user`** (seed-only) **and** `student` (reserved); `parent` is provisioned via the student-approval flow, not here. A forged `super-user`/`student` is rejected by the backend `Rule::in($assignableRoles)` → `422` (M3). |
| `src/lib/auth/session.ts` | **Fix `ROLE_HOME.student`** (§5.6); add `ROLE_HOME['super-user']='/dashboard'`; `homeForRole(roles)` resolves the primary by precedence `super-user > supervisor > teacher > employee > parent`; scope helpers unchanged. |
| `src/lib/services/index.ts` | Re-export updated `ResourceService`/`ListResult` types. |
| Report hooks (§6) | Switch from `list()` to `listAll()`/report endpoints. |
| Consumers of `filtered`/`service.list()` | Migrate to `items` + lifted pagination (grep ~30+ files). |
| `i18n/messages/ar/auth.json` + `i18n/messages/ar/profile.json` + `i18n/request.ts` | Arabic auth/error copy (**no role-select**) + profile copy; register `auth` **and** `profile` namespaces (`en` deferred). |

> **Correction to the draft's "no logic change" claim:** `session.ts` **does** change (`ROLE_HOME.student` fix + `super-user` home/precedence); `src/lib/types/user.ts` `UserRole` gains `'super-user'` and `roles.ts`/`ROLE_LABELS` add a `super-user` label (it is excluded from assignable role pickers — M3). Concrete CRUD services (`students-service.ts`, `employees-service.ts`, …) genuinely need no change (they inherit the factory).

---

## 10. Concrete backend artifact list (auth & RBAC)

| Artifact (path) | Purpose |
|---|---|
| `database/migrations/<ts>_add_domain_fields_to_users_table.php` | `username` (unique nullable), `phone` (indexed), `user_type`, `archived_at` (archive flag, not SoftDeletes). |
| `app/Models/User.php` | Update `$fillable`/`$casts`(`password`=>hashed, datetimes)/`$hidden`; relationships (`parent`/`children`/`teacherCircles`); `scopeNotArchived`. |
| `database/seeders/RoleSeeder.php` | `firstOrCreate` `super-user` + the 4 roles (+ student reserved), `guard_name=web`; `super-user` is seed-only (never granted via API/UI). |
| `database/seeders/PermissionSeeder.php` | §5.5 permissions + role grants + per-job bundles; **no grants for `super-user`** (passes via `Gate::before`); forget cache. Includes **`roles.managePermissions`** (catalog only; granted to no role — super-user reaches it via `Gate::before`) (R1/§5.11). |
| `database/seeders/UserSeeder.php` + `DatabaseSeeder.php` | roles → permissions → demo users + `assignRole` (mirror FE `DEMO_USERS`) **plus exactly one `super-user` account** (`firstOrCreate`). |
| `app/Http/Requests/Api/V1/Auth/LoginRequest.php` | `login`(email/username/phone) + `password`; Arabic messages; **no** prepareForValidation (camelCase error keys). |
| `app/Http/Requests/Api/V1/Auth/ForgotPasswordRequest.php` | `email` rule; generic flow. |
| `app/Http/Resources/UserResource.php` | camelCase incl. `roles[]` (may include `super-user`)/unioned `permissions[]`/`photoUrl`/scope fields; eager-load; assert ⊆ the 6 known literals. |
| `app/Actions/Auth/AuthenticateUserAction.php` | Resolve+verify credentials, reject archived, `createToken(device, abilities)`, transaction. |
| `app/Actions/Students/ApprovePendingStudentAction.php` | Create Student + assign level/section/bus + **create Parent User with hashed server-generated password (returned once)** (P1). |
| `app/Http/Controllers/Api/V1/AuthController.php` | Thin `login`/`logout`/`me`/`forgotPassword` **+ `updateProfile` (`PATCH /auth/profile`) + `changePassword` (`POST /auth/password`)** (or a dedicated `ProfileController`); owner-only; **never** mutates roles/permissions/branch/status/linkage (PR1); Resources only; **replace raw `/user` with `/auth/me`**. |
| `app/Http/Requests/Api/V1/Auth/UpdateProfileRequest.php` *(Phase 1)* | Validate self-editable fields only (name, single phone, email, photo); authorize owner-only; **forbids** any role/permission/branch/status/linkage key; Arabic messages. |
| `app/Http/Requests/Api/V1/Auth/ChangePasswordRequest.php` *(Phase 1)* | Validate `currentPassword` (must match) + `newPassword` (confirmed/strong); authorize owner-only; Arabic messages. |
| `routes/api.php` *(profile/password routes land in Phase 1)* | `auth` group (login/forgot throttled; logout/me **+ profile (`PATCH`) + password (`POST`)** behind `auth:sanctum`). |
| `app/Providers/AppServiceProvider.php` | `RateLimiter::for('login', …)` + **`Gate::before(fn ($u) => $u->hasRole('super-user') ? true : null)`** (M3) (Laravel 12 — no Kernel.php). |
| `bootstrap/app.php` | Middleware aliases if any (`role`/`permission`); confirm `api` middleware group. |
| `app/Policies/*.php` (+ register) | **Per-action** authorization via `$this->authorize()` — a `<domain>.<action>` permission for each `index/show/create/update/delete` (`bulk-delete` reuses `.delete`) **and** each custom action (`transfer`/`archive`/`approve`/`reject`/`resetPassword`/`publish`/`assignTeachers`/`assignRoles`/`record`/`administration.*`) (G1); **no `X-Session-Role`** — data-scope scopes keyed to the **most permissive held role** (M1). |
| `app/Http/Requests/Api/V1/{Employee,User}/{Store,Update}*Request.php` (Phase 5) | Validate the submitted `roles` with `Rule::in($assignableRoles)` (≡ `Rule::notIn(['super-user','student'])`) — a forged `super-user`/`student` returns **`422`** (M3). `$assignableRoles` (`['supervisor','teacher','parent','employee']`, picker subset `{supervisor, teacher, employee}`) is the single SSoT shared by store **and** update. |
| `lang/ar/validation.php`, `lang/ar/auth.php`, `lang/ar/passwords.php` | Arabic messages. |
| `tests/Feature/Auth/{LoginTest,LogoutTest,MeTest,ProfileTest}.php` | Happy + 401/403/422 + "only known roles"; `ProfileTest` covers owner-only profile/password edits and that they cannot change roles/permissions/branch/status/linkage; `RefreshDatabase`. |
| `config/sanctum.php`, `config/cors.php`, `.env(.example)` | Expiration, token prefix, `APP_LOCALE=ar`, stateful domains, `FRONTEND_URL`. |
| Bulk-delete route + controller method per entity (Phase 5) | `POST /<resource>/bulk-delete { ids }`, transactional (B1). |
| `app/Http/Controllers/Api/V1/RoleController.php` + `PermissionController.php` (or `RolePermissionController.php`) (Phase 5) | Runtime role↔permission management (R1/§5.11): `GET /roles` (roles + held permissions), `GET /permissions` (catalog grouped by domain), `PUT /roles/{role}/permissions` (sync); thin, Resources only; **`forgetCachedPermissions()` after every write**; reject `super-user`/`student` as `{role}`; gated by `roles.managePermissions` (super-user only). |
| `app/Http/Requests/Api/V1/Role/SyncRolePermissionsRequest.php` (Phase 5) | Validate submitted permission names ⊆ the seeded catalog and `{role}` ∈ `{supervisor,teacher,employee,parent}` (`Rule::in`, never `super-user`/`student`); authorize via `roles.managePermissions`; Arabic messages. (Canonical path/namespace: singular `Role`.) |
| `app/Policies/RolePolicy.php` / `roles.managePermissions` gate (Phase 5) | Gate the whole role-management surface on `roles.managePermissions` (held only by super-user, via `Gate::before`). |
| `tests/Feature/Roles/RolePermissionTest.php` (Phase 5) | Matrix read (`GET /roles`, `/permissions`), sync grants (`PUT`), `403` for a non-super-user, reject editing/assigning `super-user`, cache forgotten after write. (Canonical test filename.) |

Use Laravel's default `XResource::collection($paginator)` for lists (yields `{ data, links, meta }` automatically — **no custom collection**). Prefer **policies in controllers** over route middleware (backend §7); `Gate::before` grants the seeded **super-user** blanket access ahead of every policy (M3). Never return raw models.

---

## 11. Risks, edge cases & gotchas

- **Aggregate/report pages (highest impact).** Server pagination corrupts every total unless they use `listAll()`/report endpoints (§6). Enforce in review.
- **Multi-role model (M1).** The session is the **union** of all held roles — combined nav + permissions, **no** role selection, **no** switcher, **no** `X-Session-Role`. Data scoping uses the **most permissive** held role (super-user/supervisor → unrestricted; else teacher → own circles; else parent → own children). Ship the union resolution **with** the first multi-role user; never seed multi-role users before the FE handles the full `roles[]`.
- **`EmployeeRole` ≠ `UserRole` (M2).** Spatie auth roles constrained to the **6 known literals** (`super-user` + the 5); only **4 are API-assignable** (`supervisor`/`teacher`/`parent`/`employee`, with the staff picker offering `{supervisor, teacher, employee}`) — `super-user` seed-only, `student` reserved; job roles are data; per-job capability via permissions. A test guards `/me`.
- **Super-user (M3).** Exactly **one** seeded account, **never assignable**, omitted from every role picker, rejected if forged. It bypasses **both** the permission/policy layer (`Gate::before`) **and** the FE `hidden-features` mask, so it is the only identity that sees Finance/Audit/Statistics (Content is no longer masked — supervisor sees it too, C1). Guard the single-instance invariant in the seeder (`firstOrCreate`) and reject any API attempt to grant the role via a concrete `Rule::in($assignableRoles)` (≡ `Rule::notIn(['super-user','student'])`) check on the employee/user store **and** update `FormRequest` — a forged `super-user`/`student` returns `422`, not a silent grant.
- **Per-action gating + HIDE-not-disable (G1/D3/D5).** Every `index/show/create/update/delete`+`bulk-delete` is gated by a `<domain>.<action>` permission + Policy (`403` = boundary). The FE reads the unioned `permissions[]` and **HIDES** (never disables) the matching buttons/row actions and blocks pages via `RouteGuard`. Adding a resource without its permissions/Policy is a defect.
- **401 interceptor carve-out.** Acts only when a token was attached **and** the path is not `/auth/*` — otherwise it would swallow the login error or loop on `/login`.
- **Mock + RouteGuard coexistence.** Demo buttons set a sentinel token; bootstrap `/me` skipped in mock — the demo/CI flow is preserved.
- **Per-entity cutover.** Only possible via `NEXT_PUBLIC_LIVE_RESOURCES` (C1); a single global flag cannot stage it.
- **Server sort moves with pagination (L3).** Client `localeCompare('ar')` over a single page is wrong; backend whitelists sortable columns.
- **Search + pagination/abort.** Debounce, cancel in-flight via `AbortSignal`, reset page→1 on search/sort change.
- **`list()`→`ListResult` is breaking** across 30+ consumers — centralized in the factory; audit `service.list(` and `.filtered`.
- **Validation error casing.** Map camel→snake **in the Action** (not `prepareForValidation`) so 422 `errors` keys stay camelCase and match FE field names.
- **`autoPassword` (P1).** Server-generated at approval, hashed, returned once; FE stops generating it; "store as-is" deleted. Parent identifier (email **or** phone) required at approval.
- **Teachers are employees, not a separate table.** `/admin/teachers/students` survives (`exact` hide); manage teachers via Employees (`role=teacher`); `circleIds` from the employee/teacher assignment. Backend must not maintain a dual Teachers table.
- **Pending-student tuple.** Enforce `section.levelId === levelId` server-side; bus optional, region-filtered.
- **Hidden ≠ permission boundary.** Re-expose by editing `hidden-features.ts`, not `navigation.ts`; backend gates by permission regardless. `/education/statistics` and `/admin/audit` are net-hidden today.
- **Content is a live, DB-backed managed CMS (C1 — IN scope).** `/content/*` (news/library/album/site-settings/landing incl. the homepage **owner** block) is managed by **supervisor + super-user** and the **public** landing/news/album/library pages render **live from the API** (public unauthenticated `GET`; write/manage content-permission gated). Re-expose it by removing `/content` from `HIDDEN_ROUTE_PREFIXES`; the homepage owner section is now DB-backed, **not** static frontend. This reverses the earlier defaults that treated Content as out-of-scope/mock-only and the homepage owner block as static. **Finance/Audit/Statistics and legacy Circles stay hidden/out-of-scope (mock-only)** — do not change them.
- **`/messages/notices` is public-by-route** despite a TEACHING nav label — backend gates it as authenticated-only; do not mirror the quirk.
- **Student home loop (§5.6).** Fix `ROLE_HOME.student` before any student token exists.
- **Bulk ops (B1).** Single transactional `bulk-delete`; define select-all-across-pages explicitly.
- **CSRF.** Not required for PAT-bearer; only relevant if switching to the stateful-cookie flow (CORS already supports credentials) — then only `http-client`/Sanctum config change.
- **Token in `localStorage` (XSS).** Acceptable for PAT-bearer; mitigate with strict CSP, never put tokens in URLs.
- **`/profile` is intentionally any-authenticated, owner-scoped (PR1/D4).** It stays in `PUBLIC_DASHBOARD_PREFIXES` (role-agnostic) **by design** — every authenticated user edits **only their own** name/phone/email/photo + password. The backend authorizes owner-only and **never** lets a profile edit change roles/permissions/branch/status/linkage, so the "public prefix" is not an escalation hole.
- **Runtime RBAC grants are mutable (R1).** Because `/admin/roles` can re-sync any role's grants at runtime, never assume a role's permissions from the seeder alone — read them from `GET /api/v1/roles`; always `forgetCachedPermissions()` after a write so the spatie cache reflects the change immediately.
- **Performance discipline is per-endpoint (R2/§7).** N+1, unindexed filters, and chatty multi-call screens are defects, not later optimizations: eager-load every serialized relation, index every filtered/sorted/FK column, build the composite endpoints (§7.2), and cache public CMS/lookup data under **explicit namespaced keys** busted on write with `Cache::forget` (**no `Cache::tags`** — database driver, no Redis); never cache per-user/scoped data.
- **Spatie cache.** `firstOrCreate` everywhere; `forgetCachedPermissions()` after seeding.

---

## 12. Final readiness checklist

- [ ] §2 contract (Laravel-default `{data,links,meta}`, snake_case meta/query params, camelCase fields + error keys) and all §3 decisions committed to both repos, including the multi-role **union** model (no `X-Session-Role`, no role switching), the seed-only `super-user`, the per-action gating rule, the self-service `/profile` endpoints, the runtime role↔permission matrix (R1), and the performance & caching discipline (R2).
- [ ] CORS/health round-trip `localhost:3000 ↔ :8000`; Sanctum PAT mode (A1) ratified.
- [ ] `/auth/login|logout|me|forgot-password|profile|password` live, camelCase, enveloped, throttled where applicable; `/user` replaced by `/auth/me`; feature tests green; `/me` never returns a role outside the 6 known literals (`super-user` + the 5).
- [ ] `User.php` `$fillable/$casts/$hidden` updated; single-table + `user_type` strategy (U1); roles + permission catalog + per-job bundles seeded idempotently; demo users mirror FE `DEMO_USERS`.
- [ ] FE: token store + Bearer injection (**no `X-Session-Role`**) + envelope unwrap; **401 carve-out** (token-attached & non-`/auth/*`); session survives refresh via `/me`; bootstrapping gate; mock sentinel token preserves demo.
- [ ] Multi-role resolution (M1) is a **union** (combined nav + permissions, no selection, no switcher, no `X-Session-Role`); data scoping uses the most permissive held role; single-role mock fallback intact; `filterNavigation`/`requiredRolesForPath`/`homeForRole` resolve against the full `roles[]`.
- [ ] `super-user` seeded **exactly once** (`Gate::before` bypass) — bypasses permissions **and** the hidden-features mask; **never assignable**, omitted from user/employee role pickers, and **rejected with `422`** via a concrete `Rule::in($assignableRoles)` (≡ `Rule::notIn(['super-user','student'])`) on the employee/user store **and** update `FormRequest`; `/me` may include it but it is unselectable in the UI. Only **4 auth roles are API-assignable** (`supervisor`/`teacher`/`parent`/`employee`); the staff picker offers `{supervisor, teacher, employee}`.
- [ ] Runtime role↔permission management (R1/§5.11): a **super-user-only** `/admin/roles` **matrix**; `GET /roles`, `GET /permissions`, `PUT /roles/{role}/permissions` (**sync** + `forgetCachedPermissions()`); gated by **`roles.managePermissions`** (super-user only); the permission **catalog** stays code-seeded while **grants** are runtime-editable; the `super-user` role is never editable/assignable; FE **re-fetches `/me` on save** (other users update on next `/me`/login).
- [ ] Per-action FE gating: every Add/Edit/Delete/bulk control **and every custom-action control** (transfer/archive/approve/reject/resetPassword/publish/assignTeachers/assignRoles/record/administration.*) and page is **HIDDEN** (not disabled) when the matching `<domain>.<action>` permission is absent from the unioned `permissions[]` (D3/D5); `bulk-delete` reuses `<domain>.delete`.
- [ ] `/profile` works for every authenticated user (owner-only): `GET /auth/me`, `PATCH /auth/profile` (name/single phone/email/photo), `POST /auth/password` (current+new) — **backend endpoints + `UpdateProfileRequest`/`ChangePasswordRequest` shipped in Phase 1; FE page in Phase 2** (the FE never calls an unbuilt endpoint); confirmed it cannot alter roles/permissions/branch/status/linkage.
- [ ] Data layer: `ListResult`/server pagination **and** server sort in mock & http; debounced search; `AbortSignal` threaded; page resets on search/sort; `ErrorState` 401-aware; `listAll()` exists.
- [ ] Aggregate/report pages (§6) use `listAll()`/report endpoints — totals computed over the full dataset.
- [ ] Performance & caching (R2/§7): every live endpoint **eager-loads** all serialized relations (no N+1, verified by a query-count assertion / `Model::shouldBeStrict()`); migrations **index** every filtered/sorted/FK column; lists paginate (default 25, cap 100); the **composite endpoints** (§7.2, canonical `*-data`/`with-sections`/`statistics` paths) are built and each multi-service screen makes **one** call; public CMS + lookups are **cached under explicit namespaced keys + busted on write with `Cache::forget`** (no `Cache::tags`/Redis — `database` cache & queue store) with `Cache-Control`/`ETag` + Next.js ISR; per-user/scoped data is never cached.
- [ ] Navigation/RouteGuard driven by real `/me` `roles[]` (any-of union); corrected matrix (§5.4) honored; `hidden-features` mask enforced for all roles **except `super-user`** (which bypasses both the mask and the role filter); `/messages/notices` treated as authenticated-only; `ROLE_HOME.student` fixed.
- [ ] Content CMS live + public pages render from it (C1): `/content/*` (news/library/album/site-settings/landing incl. the DB-backed homepage **owner** block) managed by **supervisor + super-user** with content permissions; `/content` removed from `HIDDEN_ROUTE_PREFIXES`; public landing/news/album/library consume the **live API** (public read endpoints, write/manage gated). Finance/Audit/Statistics/legacy-Circles stay hidden/out-of-scope.
- [ ] Backend policies enforce a `<domain>.<action>` permission + data scoping (most permissive held role; **no `X-Session-Role`**) on **every** action — the CRUD verbs (`index/show/create/update/delete`, `bulk-delete` reusing `.delete`) **and every custom action** (transfer/archive/approve/reject/resetPassword/publish/assignTeachers/assignRoles/record/administration.*); teacher/parent direct-API scope returns `403`; the super-user passes all via `Gate::before`; bulk-delete transactional.
- [ ] Registration writes a real pending student with no client password; Parent User + hashed auto-password issued server-side at approval, returned once; parent identifier (email/phone) captured.
- [ ] Per-entity vertical slices green; each promoted via `NEXT_PUBLIC_LIVE_RESOURCES` before the final `NEXT_PUBLIC_USE_MOCK_DATA=false`.
- [ ] **Vertical-slice spike (Phase 4.5):** `nationalities` proven **end-to-end live** (FE wired via `NEXT_PUBLIC_LIVE_RESOURCES=/nationalities`) — envelope `{data,links,meta}`, server pagination + sort, camelCase, 401 carve-out, per-action permission gating + FE hide, cutover mechanism, and explicit-key caching all verified **before** the Phase 5 mass build.
- [ ] **Definition of Done (§4.A)** met for every shipped entity: tests incl. 401/403/404/422 + FE vitest, permission-gated (BE policy + FE hide), eager-loaded/indexed/paginated, OpenAPI updated, lint/format clean, all BE + FE gates green.
- [ ] **CI & quality gates (§4.B):** BE pipeline (composer install → env → key:generate → migrate → `php artisan test` → `pint --test`) and FE pipeline (`npm ci` → `tsc` → `eslint` → `vitest` → build) both green, with the **minimum coverage threshold** enforced as a merge requirement.
- [ ] **API contract / OpenAPI (§4.C):** an OpenAPI 3 spec for `/api/v1` is maintained (hand-authored or `dedoc/scramble`), kept in sync per entity, and validated in CI; optionally the FE TS types are generated/validated from it.
- [ ] **No Redis:** cache & queue stores are `database`; caching uses **explicit namespaced keys** busted on write with `Cache::forget` (no `Cache::tags`); public reads keep `Cache-Control`/`ETag` + Next.js ISR.
- [ ] FE gates: `tsc` 0, `eslint` clean, `vitest` green, `next build` exit 0, no `MISSING_MESSAGE`. BE: `php artisan test` green, `pint --test` clean, `migrate:fresh --seed` idempotent.
- [ ] Rollback verified: `NEXT_PUBLIC_USE_MOCK_DATA=true` (+ clear `liveResources`) restores mock mode with no code change.