---
title: Owner / Super-Admin account — precise module gating + user/company/branch limits
slug: super-admin-owner
status: in-progress
owner: hazem
updated: 2026-06-21
related:
  - setup-wizard-settings
  - self-hosted-distribution
plans:
  - https://moonui.elbaset.com/super-admin-owner-plan.html
---

# Owner / Super-Admin account (plan)

**Goal (owner):** a **Super-Admin "program owner" account baked into every install** (credentials only the owner holds) that can: (1) enable/disable any module so it **disappears from permissions + nav + is access-blocked** — "more precise" than today; (2) set **max users / max companies / max branches-per-company**; all from an **owner-only screen** the tenant admin can't override. **plan-first → [HTML plan](https://moonui.elbaset.com/super-admin-owner-plan.html).** Mapped via 4 read-only agents (BE module-gating, BE tenancy/limits, BE signing/installer, FE nav/guards).

## 🔑 Key realisation — almost everything exists; the gaps are narrow
The system is NOT greenfield. Reuse, don't rebuild.

| Capability | State | Detail (file:line) |
|---|---|---|
| Module on/off | ✅ have | FE `features/module-activation/*` + `core/services/system-modules.service.ts` (setting `system.enabled_modules`, JSON array; `MANDATORY=['core']`). Hides sidebar (`sidebar.component.ts:154-164`), topbar (`topbar.component.ts:65-95`), module-nav (`module-nav.component.ts:244`), AND **route-blocks via `moduleGuard`** (`core/guards/auth.guard.ts:162-172`, on Accounting/Sales/Purchases/LIS routes). |
| **API block by disabled module** | 🔴 GAP | BE `LogDisabledModuleHit` (bootstrap/app.php) is **LOG-ONLY** → the API stays open. Real per-module 404 gates exist for **EInvoicing** (`EInvoicingEnabled` middleware) + NPHIES — generalize that pattern. |
| **Strip disabled-module permissions** | 🔴 GAP | perms are `{module}.{resource}.{action}` (RolePermissionSeeder) → strip the disabled module's perms from the `/me` response. |
| owner / super-admin roles | ✅ have | `super-admin` = platform bypass via `Gate::before` (`app/Providers/AppServiceProvider.php:29-41`, NO perms). `owner` = per-tenant ALL perms (`RolePermissionSeeder:1083-1085`). Installer makes the first admin `owner` (`app/MoonStack/Installer/Installer.php:130`, `config/moonstack.php → owner_role`). |
| `is_owner` flag on user | 🔴 GAP | FE `User` model (`core/models/auth.model.ts:18-38`) has roles/permissions but **no `is_owner`**. Super-admin detect = `isSuperAdminUser()` (`permission.service.ts:29-34`: role `super-admin` OR empty roles+perms). Add `is_owner` to `/me`. |
| **Signing (tamper-resistance)** | ✅ have | `app/MoonStack/Packaging/PackageSigner.php` — `sign()` (:36-51) + **`verify()` (:58-70)** RSA-SHA256/OpenSSL. Private key env `MOONSTACK_PRIVATE_KEY`; public key embedded in `config/moonstack.php → update.public_key`. **Reuse `verify()` for a signed `entitlement.json`.** |
| Limits (users/branches) | 🔴 GAP (locally) | `app/Traits/TracksQuota.php` + `QuotaEnforcement` middleware (route→quota-key) + `MoonLicenseClient` exist but are **Ahmed's Moon Central** (phone-home, `config/license.php`, OFF-LIMITS). Build a **separate local equivalent** mirroring the pattern. Creation points: `UserController:75`, `BranchController:131`. |
| Multi-company | ⚠️ | Company has **no store()** — single-company-per-install (seeded). `max_companies` only meaningful if multi-company creation is enabled. |
| Storage for entitlement | ✅ have | `storage/moonstack/` (version.json, installed marker — `config/moonstack.php:29-36`). Put `entitlement.json` here. |

## Design (the plan)
**Hierarchy:** OWNER (sealed, entitlement-capped) ▸ tenant ADMIN (`owner` role, manages *within* the cap) ▸ staff.

1. **Owner account + role** — new role `owner-account` with a `Gate::before` bypass (like `super-admin`), seeded by the installer; `is_owner` on `/me` to gate the screen. The tenant admin must NOT be able to grant it or edit the entitlement.
2. **Signed entitlement** — `storage/moonstack/entitlement.json` = `{data:{owner_email,max_users,max_companies,max_branches_per_company,allowed_modules[],valid_until}, signature}`. Signed with the owner's private key, **verified at boot via `PackageSigner::verify()`**. Tampering (DB or file edit) breaks the signature → reject. This is the "admin can't override" guarantee.
3. **Precise module gating** — generalize the EInvoicing per-module 404 into ONE `EnforceEnabledModules` middleware (replaces the log-only `LogDisabledModuleHit`): any `/api/{module}/*` for a disabled module → **403/404**; + strip its perms from `/me`. Effective enabled = **owner `allowed_modules` ∩ tenant `system.enabled_modules`**. FE already hides nav + route-blocks.
4. **Limits** — a separate local `OwnerEntitlement` service + `OwnerLimits` middleware (mirrors QuotaEnforcement, reads the local entitlement, NO phone-home): block at `POST /core/users` / `POST /core/branches` when the count hits the cap; live "X of N used" counters in the owner screen.
5. **Owner screen (FE)** — `/owner` (or expand `module-activation`), gated by a new `ownerGuard` (checks `is_owner`); module toggles + limit fields + entitlement status; hidden from non-owners.
6. **Issuer CLI** — `moonstack:entitlement` builds+signs `entitlement.json` with the owner's key (sealed mode).

**🔒 Isolation:** all NEW names (`App\MoonStack\Entitlement\*`, `entitlement.json`, role `owner-account`, the 2 new middleware) — references NONE of Ahmed's Moon Central (`MoonLicenseClient`/`QuotaEnforcement`/`config/license.php`). Runs alongside. See [[self-hosted-distribution]].

## ⚠️ Honest caveat (self-hosted)
A customer with full server/DB/root access can ultimately bypass ANY local check. The **signed entitlement + boot-verify** stops the tenant *admin* (app + DB access, no signing key) — which is the real threat. The only *hard* guarantee is **phone-home** (Ahmed's domain — deferred). Recommend: signed entitlement + sealed owner account = enough for the admin-level threat.

## Decisions (MADE — owner, 2026-06-20)
1. **Owner creds:** a default super-admin owner account is **seeded in EVERY install** with default creds (config, env-overridable); the owner **changes the password in-app** and it sticks (firstOrCreate, never overwritten). ⚠️ The default password is the SAME across installs (in the repo) → **must be changed after first login** / overridden via `MOONSTACK_OWNER_PASSWORD` per build. (Future hardening: per-install random password.)
2. **Manage mode:** (a) edit-in-screen (owner-gated). Signed-file (b) optional later.
3. **Multi-company:** keep single-company; `max_companies` capped theoretically (no multi-company creation UI yet).
4. **Disabled module:** returns **404** (looks absent).

## 📍 Implementation progress + the COMPLETE roadmap (so we never solve partially)
The whole feature = **7 phases**. Each row is a discrete, testable unit; nothing below is "optional polish" — it's the full picture.

### ✅ Phase 1 — Entitlement engine (DONE, tested, committed)
- `app/MoonStack/Entitlement/EntitlementService.php` — reads `storage/moonstack/entitlement.json`, RSA-verifies its signature via `PackageSigner` (reused). **3 states:** `unsealed` (no file → no caps/unlimited), `sealed` (valid → caps), `tampered` (present+bad sig → **fail-safe lockdown**: modules→core-only, caps→0; a corrupted/forged file NEVER yields unlimited). Exposes `allowedModules/isModuleAllowed/maxUsers/maxCompanies/maxBranchesPerCompany/ownerEmail/state`.
- `app/MoonStack/Console/EntitlementCommand.php` — `moonstack:entitlement` builds+signs (owner-side) / `--show`. `config/moonstack.php → entitlement.path`.
- **Codex-reviewed** (the one session Codex ran) → applied its finding (service-level fail-safe on tamper, not caller-dependent). **4 Pest** (`EntitlementServiceTest`). Commits `e2c4d55ac` + `3de374c84`.

### ✅ Phase 1b — Owner account (DONE, tested, committed, LIVE on moonui)
- `Modules/Core/database/seeders/OwnerAccountSeeder.php` — `firstOrCreate` a `super-admin` user from `config/moonstack.php → owner_account` (email/password/name, env-overridable). Created ONCE → an in-app password change is never overwritten. `super-admin` role = `Gate::before` bypass, ABOVE the tenant `owner` admin.
- Wired into `installer.seeders` + bridge migration `2026_06_20_080000_seed_program_owner_account` (back-fills existing installs; no-ops pre-company on fresh migrate). **2 Pest** (`OwnerAccountSeederTest`). Commit `33c35e2c1`.
- **Verified LIVE:** `owner@moonerp.app` / `Owner@12345` → login 200, role `super-admin`. (CHANGE THE PASSWORD.)

### ✅ Phase 2 — Owner-only access control (FE) — DONE (committed `523f73a`, deployed, build clean)
- `ownerGuard` (passes only `isSuperAdminUser`) + a `ownerOnly?` flag on `NavItem` threaded through ALL 3 nav filters (sidebar `filterMenu`/topbar/module-nav). **Module Activation is now owner-only** (route guard + hidden from the tenant admin in every nav surface) — the owner's reported problem fixed. New `/core/owner` screen (`OwnerSettingsComponent`, ownerGuard) = module toggles (reuses `SystemModulesService`) + a limits placeholder. Verified: `/app/core/owner` 200, owner-settings chunk + i18n deployed.

### ✅ Phase 3 — Precise module ENFORCEMENT (BE) — DONE (committed `f4a0834fa`, 3 Pest, live-safe)
- `Modules/Core/app/Http/Middleware/EnforceEnabledModules.php` REPLACES the log-only `LogDisabledModuleHit` (registered in `bootstrap/app.php`): any `/api/{module}/*` for a DISABLED module → **404**. Disabled = blocked by EITHER the program-owner entitlement cap (`EntitlementService::allowedModules` — unsealed=all, sealed=list, tampered=core-only, cached 300s) OR the tenant `system.enabled_modules`. `core` never gated; unauthenticated passes; **the whole decision FAILS OPEN on any error** (a gate bug can't brick the API). Mirrors the FE `moduleGuard` (already route-blocks the same set) → closes only the direct-API gap, no new UI breakage. Verified live: moonui's enabled-module APIs still 200; moonui+moontest both carry the standard list `[core,accounting,sales,purchases,inventory,production,lis]` (excludes hrm/pos/support, which the FE already hides).
- ⏳ STILL TODO in this phase: **strip disabled-module permissions** from `/me` (cosmetic — perms for a 404'd module still appear in the list; low priority).

### ✅ Phase 4 — Limits ENFORCEMENT (BE) — DONE (committed `243fe9f36`, native + Codex reviewed)
- `Modules/Core/app/Http/Middleware/EnforceOwnerLimits.php` — global api-append (mirrors `EnforceEnabledModules`): `POST /api/core/{users,branches,companies}` → **403 at the cap** (`maxUsers` / `maxBranchesPerCompany` / `maxCompanies`). `null` cap (unsealed) = unlimited → passes; **tampered → cap 0 → blocks all** (fail-safe); any internal error **FAILS OPEN** (`report($e)`) so it can never brick creation. Acts ONLY on the bare create route (`segment(4)===null`) — ignores updates + nested actions (e.g. `branches/{id}/set-main`). The seeded **super-admin owner is excluded** from the seat count (`whereDoesntHave('roles', name=super-admin, guard=web)`). Registered after `EnforceEnabledModules` in `bootstrap/app.php`. Isolated from Ahmed's `QuotaEnforcement`.
- **Reviews applied** (rule: Codex after every task): Codex = 0 CRITICAL / 0 HIGH (confirmed off-by-one, tamper-lockdown, segment guard, isolation all sound); native found 2 HIGH → fixed: (1) null `company_id` now **fails open** (was an accidental install-wide count), (2) super-admin exclusion filters `guard_name='web'`; + `report($e)` on the fail-open path; + `EntitlementService::intCap` now treats a **negative cap as "no cap"** (never a 0-lockdown).
- **9 Pest** (`EnforceOwnerLimitsTest`: under/at-cap·unsealed·tampered·branches·super-admin-excluded·resource-specific·nested-POST·null-company) + 1 (negative-cap in `EntitlementServiceTest`) green; full MoonStack suite **46 green**. **Live-safe:** moonui is `unsealed` → null caps → zero behavior change. Creation points it guards: `UserController:75`, `BranchController:131`.

### ✅ Phase 5 — Owner screen, full (FE+BE) — DONE (committed BE `658e5a9e9` / FE `4a95260`, native + Codex reviewed)
- **BE:** `Modules/Core/app/Http/Controllers/OwnerEntitlementController.php` → `GET /api/core/owner/entitlement` (super-admin only via `hasRole('super-admin','web')`, **read-only**) → `{state, sealed, tampered, expired, owner_email, valid_until, allowed_modules, limits, usage}`. **usage counts mirror `EnforceOwnerLimits::currentCount()` line-for-line** (Codex-verified) so the "X of N" the owner sees == what the limit middleware enforces; null `company_id` → 0 (not an install-wide count). Route added in `Modules/Core/routes/api.php`. **6 Pest** (401·403·sealed·unsealed·tampered·expired).
- **FE:** `core/services/owner-entitlement.service.ts` (signals + `error` state, load guarded against re-fire) + Owner Settings screen now shows a **seal-state banner** (licensed / no-seal / seal-broken + expired) and per-resource **"X of N used"** cards (users/branches/companies) with progress bars — null cap = unlimited, tampered cap 0 = **blocked (red)**. ar/en i18n.
- **Reviews** (rule: native + Codex per task): 0 critical. Applied — `'web'` guard on `hasRole`; controller null-company guard; FE guard against load re-fire + error state; tampered-0 renders blocked not "unlimited"; `valid_until` via `date` pipe; +401/+expired tests; removed dead i18n keys. Full MoonStack suite **52 green**. **Live-safe:** moonui `unsealed` → banner shows "no seal" + real usage counts, no enforcement.

### ✅ Phase 6 — Management flow + boot wiring — DONE (committed BE `b4ce6f2ed` / FE `4319e2a`, native + Codex reviewed)
- **Honor-mode + effective wiring:** `app/MoonStack/Entitlement/OwnerLimitsService.php` resolves **effective cap = min(owner honor-mode limit, signed license)**. honor stored in the `owner.limits` setting (company-scoped). `EnforceOwnerLimits` (Phase 4) + the owner endpoint now read EFFECTIVE → the owner can only **tighten** the licensed cap, never raise it; tampered (signed 0) still blocks all.
- **Write path:** `PUT /core/owner/limits` (super-admin only) persists honor + returns the full state. FE: editable nullable number inputs (blank = unlimited) + Save + "License max: N" hint; usage cards now show used/**effective**.
- **🔒 SECURITY (native CRITICAL + Codex HIGH — both caught a gap I missed):** a tenant admin could write `owner.*` via OTHER settings endpoints — notably **WebStore admin settings** `PUT /store/admin/settings`, which loops `$request->all()` → `SettingsService::set` with no key filter. Fixed with a **service-layer backstop in `SettingsService::set()`** — `owner.*` refused for any authenticated non-super-admin regardless of which controller calls it (+ `not_regex` on the Core settings request as defense-in-depth). Also: `updateLimits` guards a null company_id (no phantom company 0).
- **7 Pest** (honor enforced · license hard cap · effective=min · owner-only · generic-settings refused · tampered-blocks · service backstop) + full MoonStack **59 green**. **Live-safe:** moonui unsealed → the owner sets their own honor limits, no license ceiling.

### ⏳ Phase 7 — Runtime tamper UX — REMAINING
- When `EntitlementService::isTampered()`: a lockdown banner + the enforcement already fail-safes (core-only, no new users) until a valid seal is restored.

**🔒 Isolation maintained:** everything is `App\MoonStack\Entitlement\*` / `config moonstack.*` / the `super-admin` role / new middleware — references NONE of Ahmed's Moon Central.

## Status (2026-06-21)
- ✅ **ALL 7 phases DONE.** Phase 7 (runtime tamper UX) committed BE `73f9d02e3` / FE `7186c33` (native+Codex APPROVE): `GET /core/entitlement/status` (auth, read-only, no sensitive data) + an app-wide **lockdown banner** in the main layout (tampered=red safe-mode / expired=amber, owner link for super-admins; advisory — a failed check never blocks the app). 4 Pest. ~41 Pest total, full MoonStack suite 66 green.
- ✅ Phases **1, 1b, 2, 3, 4, 5, 6 DONE** — entitlement engine + owner account + FE owner-only gating + BE module enforcement + BE limits enforcement + owner screen (state/limits/usage) + **honor-mode limit editing & effective-cap wiring** — committed on `hazemdev` (`…243fe9f36`, BE `658e5a9e9`/FE `4a95260`, BE `b4ce6f2ed`/FE `4319e2a`), **~37 Pest green**, owner login LIVE, FE deployed.
- ⏳ **Phase 7 remaining (last):** runtime tamper UX — a lockdown banner when `EntitlementService::isTampered()` (enforcement already fail-safes to core-only / caps 0). (+ minor: strip disabled-module perms from `/me`.)
- ⚠️ Phases 4–6 committed but **NOT yet released** to the fleet (live-safe on moonui since unsealed). Needs a release. Codex runs (namespaces enabled this session) — **rule: Codex review after every task** ([[feedback_codex_review_after_each_task]]).
- 🔑 Owner login (seeded default; may be changed in-app): **`owner@moonerp.app` / `Owner@12345`** (role `super-admin`). Env-overridable via `moonstack.owner_account`.
- 🛒 **Store module now owner-gateable** (committed BE `2bf9966c0` / FE `e66f0be`, native+Codex APPROVE): the backend-only WebStore (`/api/store/*`, no FE pages) added to `EnforceEnabledModules` MODULE_MAP + a BACKEND_ONLY toggle on the owner screen; grandfather migration `2026_06_21_100000` appends `store` to existing `system.enabled_modules` (additive/idempotent) **and flushes the `module-gate:{c}` + `settings.company.{c}` caches** so it's instant (no 5-min 404 window). 3 Pest. **Release caveat (Codex M1): migrate-before-reload** so the gate doesn't 404 store before the migration runs (the MoonStack updater already migrates before finalize).
- ⚠️ **Live moonui multi-company gotcha:** the super-admin owner is on **company 1 (Moon ERP Demo)** but the real lab work is on **company 4 (بلاك سيركل)** (`admin@moonerp.com`/`hazem@gt4it.com`). All data is company-scoped → logging in as owner shows company-1 (demo) data, NOT a data-loss. On real single-company installs this never happens. **Open requests:** (1) make module activation + owner limits **install-wide** (not per-company); (2) a **company picker at login** (multi-company) — scope decision pending (super-admin-only vs many-to-many users).

## Lab-only confinement (2026-06-26, BE `9fc7d17d0` + FE `4246b27`)
**Goal:** a lab-only customer's users stay entirely inside `/lab` (never see the ERP shell), but **accounting stays enabled** (the lab posts GL). Super-admin controls it; opens the full ERP for customers who buy it.
- **Key enabler:** lab GL posting is **in-process** (`Modules/LIS/app/Actions/PostLabInvoice.php:43` + `PostLabPayment.php:50` call `Accounting\Actions\CreateJournalEntry` directly, not via `/api/accounting`), so hiding/blocking accounting's UI+API never breaks the lab. `/lab` has its own financial screens (invoices/payments/treasuries/cash-flow/cashier/financial-reports/cost-dashboard).
- **Canonical "lab-confined role"** = `Modules/LIS/app/Support/LabRoleAssignment.php:39-66` (every perm is `lis.*` OR in `SHELL_PERMISSIONS` allowlist; not super-admin). FE **mirrors** it: `permission.service.ts` `LAB_SHELL_PERMISSIONS` + `isLabConfinedUser` + `isUserConfinedToLab` (role OR install flag). **Keep the allowlist in sync both sides.**
- **Two super-admin levers:** (1) **modules** — existing `POST /core/entitlement/modules` + `EnforceEnabledModules` (lab-only = `core,lis`; buy accounting = add `accounting`). (2) **NEW lab-only flag** — owner-only setting `owner.lab_confined` (the `owner.*` namespace is owner-only-writable at `SettingsService::set`), set via `PUT /core/owner/lab-confined` (`OwnerEntitlementController::updateLabConfined`, fan-out to all companies as `'1'/'0'`), surfaced on `/me` as `lab_confined`, toggle in Owner Settings (`/core/owner`, "Lab-only edition", confirm-before-ON).
- **Enforcement (FE):** `confinementGuard` (`auth.guard.ts`) on the MainLayout route + pos/hr/factory/clinic shells (NOT `/lab`). Confined user → `/lab`. **Loop-safety (code-review HIGH, fixed):** guard only redirects to `/lab` when the user has a `lis.*` perm (else falls through → `/access-denied`); `landing.service` returns null (not `/lab`) for a no-lab confined user. Super-admin never confined.
- **No migration/seed** — the flag is a setting (created on first toggle; absent = false). Ships with any release.
- ⏳ Optional next: Phase-3 BE defense (403 non-lab `/api/{module}` for confined users) — not done; upgrade the flag to the signed entitlement (`edition:'lab'`) later for tamper-resistance.

## ✅ "Role edits don't save / revert" = permission DEPENDENCY CLOSURE, not a save bug (2026-07-01, elmadina)
**Symptom (elmadina, a `lab_manager` editing "Lab Director" role id=10, all-54-`lis`):** un-check a permission → «تم الحفظ» → reload shows it back. Owner: «الجروبس بتاعت البرمشن لا يمكن التعديل عليها».
**Root cause (root-cause-analyst, full evidence chain):** NOT a persistence/cache/403 bug. The save is real (200, DB written, FE re-fetches fresh — no NgRx/HTTP cache). `RoleSaveService::resolvePermissions` (`Modules/Core/app/Services/RoleSaveService.php:262-277`) re-expands the chosen set to its **transitive dependency closure** via `LisPermissionDependencies::expand` (explicit `EDGES` + generic rule "every `lis.{res}.{action}` needs `lis.{res}.view`"). So un-checking a permission that another still-selected permission REQUIRES → silently re-added. **Selective:** prerequisites revert; true leaves (`samples.reject`, `results.enter`, `patients.update`) stick. Broad roles (Lab Director) → almost every base/`.view` is a prerequisite → "nothing sticks." Proven by live audit_log (user 1 saved role 11 4× in 2 min, byte-identical 38-perm set each time) + direct repro on elmadina data.
**The real defect = UI honesty gap:** `lis-roles.component` showed a lock icon but the checkbox was NOT disabled → you could un-check something the server puts back.
**Fix (FE-only, no BE/server change — closure is correct/intentional):** owner asked «اعمل الي يخلي الموضوع اسهل» → chose **CASCADE-REMOVE** over lock. `lis-roles.component` `togglePerm`: un-checking a permission now calls `removeCascade(name)` = removes it + EVERY currently-selected perm that (transitively) depends on it (via the catalog `dependencies` map, `LisPermissionDependencies::mapFor`, which includes the generic `.view` edges), + an info toast "also removed N". `toggleGroup` un-check cascades each. So the role actually shrinks and nothing bounces back. `isRequiredDep` kept as a soft `pi-sitemap` hint (not a lock). First shipped a LOCK variant (FE `154a979`, disabled checkbox) then replaced with cascade per owner. **Workaround before release:** un-check dependents first. ⏳ owner release→elmadina. (If the owner ever wants removal to win over coherence at the server, that's a separate BE change in `resolvePermissions:262-277` — risks non-functional roles.)

## ✅ Lab admin manages lab-confined roles from inside the lab (2026-06-28, shipped 4.0.27)
**Problem:** the Lab → Roles screen (`/lab/roles`, `LabRoleController`) 403'd «Global roles can only be modified by a super-admin» when the tenant owner/admin edited or deleted a lab role — because built-in lab roles are GLOBAL (`company_id null`) and `RoleSaveService::assertCanModify` gates global-role writes to super-admins. The owner saw "super-admin only", no edit/delete. (Owner wanted: owner = full master INSIDE the lab — add users, raise/lower perms, make another lab admin — but confined OUTSIDE; `lab_confined=1` is the outer wall, not a power-strip.)
**Fix (code, durable):** `RoleSaveService::update` gained `skipGlobalGate`; `LabRoleController::update/destroy` skip the global super-admin gate ONLY for roles proven lab-confined (`LabRoleAssignment::isAssignable` = every perm is `lis.*`+shell). `lisOnly` sync can't add anything outside `lis.*`, so no escalation; non-lab/cross-module global roles still need super-admin. BE `0ae0b5f40`, +3 Pest (`LabRoleManagementTest`), updated the C2 `RoleLifecycleTest` contract (lab path now allows confined globals; Core path still 403).
**Immediate elmadina data fix (also applied):** the migrated lab roles were `company_id NULL` (global) → owner blocked. `UPDATE roles SET company_id=1 WHERE name IN (lab roles) AND company_id IS NULL` (elmadina + staging) made them tenant-owned → owner edits freely. Code fix means future installs work even if lab roles ship global.
**⚠️ Pre-existing failing test (NOT mine):** `RoleLifecycleTest › C2: another company role invisible` fails on a clean checkout (confirmed via stash) — a parked C2 contract test.
