Skip to content

0012 — Per-role scopes in the vocabulary

Status: Accepted; extends 0010 Date: 2026-09-27

Extends 0010.

Context

0010 gave a thing a scopes list — the environments it exists in at all — and checked every concrete grant's scope against it. That check is thing-wide: every role a thing declares is valid on every scope the thing declares, with no way to say otherwise. In practice a thing's roles do not always share that shape. An ssh thing declared across core, devel, stage and prod might have an admin role that legitimately belongs on all four, and a user role meant only for a break-glass login on devel — but the vocabulary as 0010 left it could not say that: core:ssh:user validated exactly as cleanly as devel:ssh:user, even though the second is the only one anyone meant to grant.

The cost is the same one 0010 exists to remove for scopes and things generally: a name that looks checked but is not, discovered only when someone notices a grant that should never have loaded. The difference here is granularity — the mistake is not in the scope or the thing, both of which are correctly declared, but in the combination of scope and role.

Decision

A role's own value in things.<t>.roles may restrict it to some of its thing's declared scopes. The YAML shape stays backward-compatible: a role's value is EITHER the plain list of implied roles 0010 already accepts (admin: [operator], viewer: []) OR an object naming implies and/or scopes explicitly (user: { scopes: [devel] }), both keys optional. policy.RoleSpec.UnmarshalYAML reads either shape into the same struct; every policy written before this decision used the list form, which still means exactly what it always did — a role restricted to nothing, i.e. valid on every scope its thing declares.

A role's own scopes, when declared, must be a non-empty subset of its thing's own scopes, checked at load: a scope the role names that is not declared under vocabulary.scopes at all, a scope the role names that its own thing does not have among things.<t>.scopes, and an explicit empty list are each refused, distinctly, because each is a different authoring mistake.

A concrete grant is refused when the role does not cover its scope, checked after the existing thing-scope check and reported distinctly from it: core:ssh:user is refused with a message naming the role's own scopes (role "user" of thing "ssh" is valid only on scopes [devel]), never conflated with ssh not declaring core at all, which is a different, already-existing refusal.

A mapping wildcard skips a combination the role disallows, exactly like it already skips a thing that lacks the role. *:ssh:user expands to devel:ssh:user alone; core, stage and prod are left out silently, the same way devel:*:viewer already silently leaves out a thing with no viewer role. The existing "a wildcard that expands to no group is refused" rule gains one more reason a wildcard can come back empty — every candidate ruled out by the role's own scope restriction — and Vocabulary.emptyWhy explains that reason by name rather than folding it into the generic sensitivity message.

An implies edge is refused at load when its target does not cover every scope its source does. admin: [user] where admin is valid on every scope (the default) and user restricts itself to devel alone is refused, naming the scope the mismatch would have lost (role "admin" implies "user", but "user" is not valid on scope "core", which "admin" is). Silently intersecting the two — granting admin only as much user as user itself covers — was rejected: see Alternatives.

Consequences

A conservative default, matching 0010's own posture: a role that declares no scopes restricts nothing, so no existing vocabulary changes shape or behaviour by upgrading past this release. Every table in reference/policy.md#vocabulary and taxonomy.md that already worked in terms of "the role exists on the thing" now also means "and the role covers this scope," which is a no-op check for a role that names no scopes of its own.

The inheritance rule is a real constraint on how a ladder may be authored, not just a validation nicety. A thing whose most-restricted role sits below a broader one in the ladder — user scoped to devel implying admin which is valid everywhere — is fine, since implying upward in coverage is exactly what it looks like. The mistake this catches is a broader role implying a narrower one: admin (everywhere) implying user (devel only) can never be declared this way once user's restriction exists; the ladder has to be re-shaped, or admin's implies has to point at a role that covers what admin does.

Explainability keeps its shape. policy.Result's Held, Key and Implies fields (0010) need no change: a role-scope restriction changes which grants exist to be held or implied, not how a hold is recorded once it exists.

Alternatives considered

Intersecting scopes across an implies edge, rather than refusing a mismatch: admin (everywhere) implying user (devel only) would silently grant user on devel alone wherever admin is held, with no error. Rejected: it is exactly the kind of silent narrowing 0010 already rejected once, for the same reason — an author reading admin: [user] would reasonably expect holding admin anywhere to hold user there too, and a ladder that quietly does not do that for some scopes is a footgun discovered by someone missing access, not by the loader.

Scoping at the implies edge itself, rather than on the role (admin: [{role: user, scopes: [devel]}]). Rejected: it moves the restriction to the wrong place. A role's own scopes describe what the role legitimately grants, once, however it is reached — directly or through any number of implies edges — while scoping the edge would let two different roles imply user with two different scope subsets, answering "which scopes does user cover" with "it depends which role you ask," which is not a question this schema should have two answers to.

A thing-level default scope for a role name shared across things (declaring user scoped to devel once, applying to every thing with a user role). Rejected: 0010 already settled that a thing's role ladder is the thing's own — admin on k8s and admin on argocd share nothing but a name — and a cross-thing default for a role's scopes would quietly resurrect exactly the coupling that decision closed off.