D36 — Per-module permission registry, both guards, registry seeded FROM the enums (extends D24)
Architecture decision record, relocated verbatim from the retired single-file
docs/tenancy/decision log. Status, thematic clusters, and how to record a new ADR: the decision log index.
ID note: the work item asked for IDs "D27, D28", but those are taken by the money-safety pillars (D27–D32), and D33–D35 are taken by the Features/Entitlements engine (merged from the sibling branch). To keep the log append-only and collision-free (the actual intent), these land as D36/D37.
The permission catalog is no longer a single central list — each module declares the permissions it
owns. A module exposes src/Authorization/PermissionProvider.php (implements
Modules\Authorization\Permissions\Contracts\ProvidesPermissions, returns PermissionDefinition[])
and registers them into a central, app-wide Modules\Authorization\Permissions\PermissionRegistry
(a singleton, owned by the Authorization module) from its ServiceProvider boot(). This mirrors how
each module surfaces its domain events from its own src/Events. The registry covers both guards:
operator (web, spatie global roles) and tenant (client, the tenant_roles tables). A
RegistryValidator fails the build (run by permissions:sync + the registry-integrity test) on a
duplicate key, an unknown / cross-guard dependency, a cycle, or a dependency chain deeper than depth
2 (A→B→C).
Single source of truth (enum-delegation choice). We chose (b): the registry is seeded FROM the
enums, not the enums delegating to the registry. Enums\BackofficePermission +
Enums\TenantPermission stay the authored source for the keys they own; the Authorization module's
own PermissionProvider lifts each case into a PermissionDefinition. Runtime checks remain the
plain, direct enum-based calls ($actor->can(BackofficePermission::X->value) for operators,
$actor->hasTenantPermission(TenantPermission::X) for clients), so the registry is never on the
authorization hot path. Each key is declared by exactly one module — there are never two divergent
sources for the same key.
RoleComposer is the single role-build chokepoint. Both role-CRUD paths (backoffice spatie roles
and tenant tenant_roles) compose through Modules\Authorization\Permissions\RoleComposer, which
expands a desired set into its dependency closure and validates completeness. permissions:sync
creates/forgets the spatie web permissions from the registry and validates the registry +
DefaultRoles; it is idempotent. An out-of-bus façade
(Modules\Authorization\Permissions\Facades\Permissions over PermissionGate) routes a single
permission to the right guard mechanism for UI/middleware checks outside the bus.
Worked example + reconciliation. Only the Team module is migrated as the worked example: the
legacy tenant.team.* permissions moved out of TenantPermission into the Team module's own
Modules\Team\Enums\TeamPermission (renamed to the module.entity.action shape team.members.*) and
are declared in app-modules/team/src/Authorization/PermissionProvider.php. hasTenantPermission()
and TenantRole::grantPermissions() were widened from the concrete TenantPermission enum to the
PermissionKey contract so any guard's permission identifier works. Because the Authorization module
must not depend on Team (module-graph direction), the default owner/admin tenant roles now receive
the full client-guard registry catalog at seed time (SeedDefaultTenantRoles via the registry +
RoleComposer) rather than a static enum list — so they pick up per-module permissions automatically.
Migrating the rest of the catalog into per-module providers, and the role-management CRUD/UI, remain
tracked follow-ups (docs/tracking/permission-catalog-migration.md, permission-role-management.md).