D37 — Permission dependencies are enforced at role-build time only, never on the runtime hot path
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.
A PermissionDefinition may declare dependsOn (other keys, same guard). Dependencies are resolved
only when a role is composed/edited: selecting a dependent permission auto-adds and locks its
dependencies (RoleComposer::compose), and a role holding a permission without its dependencies is
rejected (RoleComposer::validate). The runtime check — $actor->can() / hasTenantPermission() and
the bus authorize() gate — stays a single-permission lookup and trusts that role construction
already closed the set. We deliberately do not expand dependencies on the hot path.
Rationale: authorization runs on every guarded request; dependency expansion is graph traversal that
would add cost (and a stale-registry failure mode) to the most frequently-executed code in the app.
Roles are built/edited rarely, so that is the correct place to pay for the closure once. Roles remain a
UX-only grouping — authorization decisions always go through permission checks; a role name never
appears in an authorize() gate. Depth is capped at 2 (A→B→C); the worked example is
team.members.invite → team.members.list (you cannot invite into a team you cannot see).