---
name: authority-limits
description: General "authority limits" layer — numeric per-role/per-user ceilings (first case max discount %) over boolean Spatie permissions. How it works + how to add a new limit.
updated: 2026-07-04
status: SHIPPED to hazemdev + /app (2026-07-04); owner ships via release
plans:
  - https://moonui.elbaset.com/authority-limits-analysis.html
---

# Authority Limits (الصلاحيات ذات القيمة)

**The ask (owner, 2026-07-04):** permissions are boolean, but some authority is a
NUMBER — e.g. "this user may apply discount up to 10% on a request." Settable at the
ROLE (group) and USER level, generalized so future limits (refunds, credit ceilings,
price overrides) are cheap. First instance: **max manual discount % on a lab request.**

Analysis + Fable ruling: `authority-limits-analysis.html`. Fable closed every design
decision; a native code review then caught 2 HIGH + 3 MEDIUM (all fixed — see below).

## The design (thin layer over the settings store — NO new table, NO Spatie change)
- **Registry (code):** `Modules/Core/app/Support/AuthorityLimitRegistry.php` — a plain
  PHP constant map. Each limit = `{key, label_en/ar, unit(percent|currency|count|days),
  type(ceiling→MAX / floor→MIN), default(null=unlimited), max, bound_permission?, module}`.
  It is the schema. It does NOT auto-enforce.
- **Storage = the settings store** (mirrors `roles.<name>.nav_config`):
  - role limits → company-scoped key `roles.<name>.authority_limits` (JSON blob)
  - user override → user-scoped key `authority_limits` (JSON blob, `user_id` scope)
  Read with `SettingsService::getExact()` (scope-EXACT, no fallback) so a phantom
  company-scoped `authority_limits` row can't be misread as a user override.
- **Resolution** (`AuthorityLimitService::resolve`): **"user wins; else the most
  generous role; else the default."** super-admin/owner → unlimited. A role that does
  NOT set the key contributes NOTHING (an unset filler role can't widen an explicit cap).
  User value is a HARD override (up OR down).
- **Enforcement** (`AuthorityLimitService::assertWithin` → 422 `AuthorityLimitException`
  with `{code:'authority_limit_exceeded', limit_key, max_allowed, attempted}`) is a
  MANUAL call at each limit's domain choke point. For discount that is
  `LabRequestService::recalculateTotals($request, $enforceFor)` (new optional
  `?Authenticatable` param — all other callers stay byte-identical with the null default).
- **/auth/me** carries a resolved `limits: {key:number}` map (unlimited keys omitted) —
  cosmetic FE pre-validation; the BE is authoritative.

## Discount enforcement specifics (the money-critical part)
- Enforced on the **EFFECTIVE percentage** `manualDisc/grossNet*100` so a FIXED amount
  can't bypass a % cap (Fable trap #1).
- Fires on: **create** (whole write+recalc wrapped in a transaction so a 422 leaves no
  orphan), **discount-changing update** (grandfathering: an unchanged over-cap discount
  can still be re-saved — gated on `$discountChanged` computed from pre-write originals),
  and **investigation REMOVAL** (shrinking the base inflates a fixed discount's effective
  %; wrapped in a transaction). Adding a test only grows the base → never enforced.
- **default = null (unlimited)** → zero behaviour change on upgrade until an admin sets a cap.

## Admin UI (FE)
- **Lab → Roles → "Limits" tab** (`lis-roles`): catalog-driven numeric inputs, saved with
  the role payload (`authority_limits` map; blank input = omitted, NOT 0).
- **Lab → Users** (`lis-users`): per-user override (blank = inherit role), best-effort on save.
- Wizard clamps the discount input to the cap + shows "your max discount X%" + surfaces the 422.
- `PermissionService.limit(key)` reads the resolved cap; catalog via `GET /core/authority-limits`;
  per-user via `GET/PUT /core/users/{id}/authority-limits`.

## HOW TO ADD A NEW LIMIT (the whole point of the registry)
1. Add one entry to `AuthorityLimitRegistry::LIMITS` (key, labels, unit, type, default, module).
2. Call `AuthorityLimitService::assertWithin($user, $key, $value)` at that limit's choke
   point (inside a transaction if a rejection must roll back a write).
3. That's it — the role editor, user editor, `/auth/me`, validation and catalog all pick it
   up generically. (FE role/user editors filter the catalog by `module`.)

## Review fixes applied (native review, Codex was blocked — `max_user_namespaces=0`)
- **HIGH-1:** user-blob read now `getExact` (no fallback) → a company-scoped bare
  `authority_limits` injected via the generic `PUT /core/settings` can't act as a
  company-wide phantom override.
- **HIGH-2:** `AuthorityLimitValidator` registered under the `roles.` prefix too (and
  guards on the `.authority_limits` suffix) so a role blob written via the generic
  settings endpoint is validated at the choke point; every other `roles.*` key (nav_config)
  passes through untouched.
- **MEDIUM-2:** `removeInvestigation` re-asserts the cap (base-shrink bypass) in a transaction.
- **MEDIUM-3:** role name too long for the `varchar(100)` setting_key → clean 422 not a 500.
- **LOW-1:** label tightened to "Max **manual** discount" (package discounts are not capped).
- **Accepted/documented:** MEDIUM-1 (a rejected create burns one request-number sequence
  value — `generateNext` commits before the transaction; rare error path, standard for ERPs).
- **Trap #4 (audit) already covered:** limit writes go through `Setting::updateOrCreate`;
  `Setting extends BaseModel` uses the `Auditable` trait (spatie `LogsActivity`
  `logAll()->logOnlyDirty()`) → every role/user limit change is auto-logged (old→new
  `value` blob + causer) in the Activity Log. No extra code needed.

## Verify / test
- `vendor/bin/pest Modules/Core/tests/Feature/AuthorityLimitTest.php Modules/LIS/tests/Feature/LabRequestDiscountLimitTest.php` — **25 green** (resolution semantics, both discount modes, remove-bypass, grandfathering, rename hook, phantom-override + role-blob validation regressions).
- Zero migrations. Zero regressions (the Role/User/Setting suite failures are pre-existing — proven via `git stash` baseline).

## SHAs / deploy
- BE hazemdev `be00e2507` · FE hazemdev `a13b248` · deployed `/app` · CHANGELOG `[Unreleased]` (bilingual). **Owner ships via release.**

## Owner test (on moon)
Lab → Roles → pick a role (e.g. reception) → Limits tab → set Max manual discount 5% → save.
Log in as a user with that role → new request → discount step → typing >5% is clamped + a
hint shows "your max 5%"; a fixed amount worth >5% is rejected on save with "your limit is 5%".
Owner/admin stays unlimited.

## Files
Registry `Modules/Core/app/Support/AuthorityLimitRegistry.php` · Service
`.../Services/AuthorityLimitService.php` · Exception `.../Exceptions/AuthorityLimitException.php`
· Validator `.../Support/AuthorityLimitValidator.php` · Controller
`.../Http/Controllers/AuthorityLimitController.php` · rename hook + role persist
`.../Services/RoleSaveService.php` · `getExact` `.../Services/SettingsService.php` · enforcement
`Modules/LIS/app/Services/LabRequestService.php` (`recalculateTotals` + `removeInvestigation`),
`.../Actions/CreateLabRequest.php`, `.../Http/Controllers/LabRequestController.php`. FE:
`core/services/authority-limit.service.ts`, `permission.service.ts`, `features/lis/{roles,users,request-wizard-v2}`.
