The role roster, at .openstation/roles.yaml. It answers two questions nothing else can:
which roles exist, and what each one grants.
A role is one named object with two sides. Which side applies depends on who names it:
- admission — a person holds a role (
people.yaml), and a space admits roles (channels[].roles). This is the older half, andscope: personis what keeps a role usable here. - authority — an agent lists roles (
agents.<name>.roles), and they compile into the gate Claude Code enforces.scope: agentis what keeps a role usable here.
scope is required, never defaulted: a role usable by both consumers must be a deliberate
statement, not what omission gives you.
The file is optional. Without it, gate files stay hand-authored and every check on this
page is skipped, so an existing workspace keeps booting unchanged. Creating it is what makes
the gates generated — after which openstation <ws> gates write owns those files.
Shape
roles:
var-sealed:
scope: agent
required: true
description: Platform state is not agent-readable.
deny:
- Read(var/**)
- Read(//Users/leonid/openstation/hn-support/var/**)
clone-git:
scope: agent
description: >
Git in the code-improver's own clone. Push is pinned to improve/* — that pin
is what stops a push to main, so never widen this to a bare git allow.
allow:
- Bash(git -C /Users/leonid/openstation/hn-support-code/openstation status:*)
- Bash(git -C /Users/leonid/openstation/hn-support-code/openstation push -u origin improve/*)
identity-probe:
scope: agent
description: Confirm which GitHub account this agent is, without reading the token.
hooks:
PreToolUse:
- matcher: Bash
command: python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/allow_identity_probe.py"
admin:
scope: person
description: The operator. Admitted to the admin DM.
Fields
| Field | Required | Default | Meaning |
|---|---|---|---|
scope |
yes | — | person | agent | both. Which consumers may name this role |
allow |
no | [] |
Claude permission patterns, verbatim, composed into the gate's allow list |
deny |
no | [] |
Same, for the deny list. Deny always wins — that is Claude's own precedence, not ours |
required |
no | false |
Boot refuses an agent that neither holds nor exempts this role |
description |
no | — | Re-emitted as _comment_<role> in the generated gate, so why a rule exists survives the file becoming machine-owned |
hooks |
no | — | { <event>: [{ matcher, command }] }, scoping a hook to the roles that need it |
Role names obey the same rule roster tokens do — ^[A-Za-z0-9][A-Za-z0-9_-]*$ — because a
person-scoped role becomes a .openstation/profiles/<role>.md path segment.
Attaching roles
agents:
code-improver:
claudeSettings: code-improver
roles: [var-sealed, clone-git, identity-probe]
allow: [Edit(//Users/leonid/openstation/hn-support-code/**)] # one-off, this agent only
deny: [Bash(gh pr merge:*)]
verifier:
claudeSettings: verifier
roles: [var-sealed]
exempt:
eval-zone-sealed: "the verifier IS the eval scorer — reads answers/, writes results/"
Rules union: every role's, in declaration order, then the agent's inline allow/deny. Exact
duplicates collapse. Nothing is resolved here — both lists are emitted and Claude applies its
own deny-wins precedence.
Adding a role can grant capabilities that are not already denied, so review its allows.
A role granting Bash(git …) cannot override another role's deny; existing denies still win.
Two directories, one roster
The part most easily misread. .openstation/profiles/ already holds two unrelated kinds of
file under one naming convention:
| File | What it is |
|---|---|
admin.md, member.md |
behaviour for a person holding that role |
triage.md, verifier.md |
behaviour for an agent |
Nothing in that directory distinguishes them, and requireProfile asserts exactly one thing:
that a role named at an admission site has a .md to run under. That is not a roster — it
cannot tell you which roles exist, and it never sees people.yaml.
roles.yaml is the roster. requireProfile keeps its narrower assertion. Two claims about
the same name, neither redundant:
roles.yaml does `admin` exist? what does it grant? which consumers may name it?
profiles/admin.md what does a person holding `admin` behave like?
So adding a role means up to two files, and which two depends on scope:
| Adding | roles.yaml entry |
profiles/<role>.md |
|---|---|---|
| A person-role admitted at a channel | required | required — requireProfile refuses without it |
| An agent-role composing a gate | required | never — it is not behaviour, and no admission site names it |
scope: both |
required | required, because an admission site may name it |
A role never selects a profile, and roles.yaml has no profile: field for that reason.
See people.yaml: a role that implied a profile would carry privileged
behaviour into every space its holder stands in. Behaviour stays in profiles/; authority
lives here.
Load-time errors
All of these refuse the boot, naming the file and the field path.
| Error | Why it is worth refusing |
|---|---|
| A role named anywhere is absent from the roster | A typo otherwise costs a capability or an admission in silence |
scope mismatch for the slot |
An agent granted admin, or a person granted var-sealed |
A people.yaml role absent from the roster |
Previously checked for name pattern only, so role: admni silently dropped that person to defaultRole and admitted them nowhere |
A required role neither held nor exempted |
The omitted-deny class of privilege escalation. This was prose in a workspace README before it was a check |
exempt: with an empty reason |
An undocumented hole |
exempt: naming a role that is not required |
A typo that reads as protection while granting nothing |
A Write(...) entry in an allow list |
Inert. An Edit(...) allow already grants both the Edit and the Write tool, so this grants nothing while looking like it does |
| A single-leading-slash absolute path | Matches nothing, ever — it reads as a project-root-anchored gitignore pattern. Use //-absolute |
| A gate file that has drifted from the roster | The lockfile guarantee. Boot refuses; it never regenerates behind you |
Generated gate files
openstation <ws> gates write compiles the roster into each
.claude/settings.<label>.json, which carries a _generated marker and one _comment_<role>
per role with a description.
gates check compares them again and exits non-zero on drift — by rule set, not bytes, so
a reordered emit or an edited comment is not drift.
write is operator-run by construction, not by convention: Claude Code write-protects
.claude/** from headless agents regardless of permission rules, so an agent invoking it fails
on the write itself. Agents and boot use the read-only paths.
What this is not
Not the enforcer. Claude Code enforces the gate file; the roster is how that file gets
written. roles.yaml grants nothing the compiled file does not contain.
Not a tool ceiling. The advisory verb-level ceiling is separate and lives with the profile — see permissions. The gate narrows by path; a ceiling narrows by verb. Conflating the two once emptied the ceiling∩grant intersection and failed every turn.