Queue workers get a persona and capability scope at spawn time, instead of every worker being
the same generic implementer. This doc covers how a role is resolved, where each piece lives, how to
change it, and how to roll it back.
The three “role” variables — do not conflate them
This is the single most likely source of confusion in this area. All three exist simultaneously.
Variable
Set by
Means
Values
ROLE
spawn-worker.sh positional arg 7 (#1706)
dispatch routing class — picks which agent prompt file to load
infra | dev | orchestrator | ""
P24_CLAUDE_ROLE
~/.bashrc per machine (_setup-claude-env-ansible.sh)
machine standards profile — which standards/ docs a session loads
CLAUDE_ROLE is the only one this document is about.
Where things live
Asset
Repo path
Deployed to
Deployed by
Role definitions (+ capabilities: frontmatter)
.claude/agent-prompts/roles/*.md
~/.claude/agent-prompts/roles/ on vps-i1, vps-h1, bms-4
sync-claude-skills.yml
Role-scoped worker prompt
infra/agent-prompts/worker-issue-scoped.md
/opt/p24-infra/infra/agent-prompts/ on vps-i1, bms-4
sync-worker-roles.yml
Per-repo context files
infra/repo-contexts/*.md
/opt/p24-infra/infra/repo-contexts/ on vps-i1, bms-4
sync-worker-roles.yml
Resolution logic
scripts/spawn-worker.sh
/opt/p24-infra/scripts/
deploy-vps-scripts.yml + sync-worker-roles.yml
Shape validation
scripts/tests/test_worker_role_capabilities.py
CI only
—
Ownership boundary:sync-claude-skills.yml owns ~/.claude/**; sync-worker-roles.yml owns
/opt/p24-infra/infra/**. Never let one write the other’s destination — that is a last-run-wins
race. Both workflows carry this note in a header comment.
How a role is resolved
In scripts/spawn-worker.sh, before the agent-prompt selection. First non-empty wins:
Explicit CLAUDE_ROLE already in the environment — escape hatch for manual invocation.
Job type, via this map (mirrored from the ROLE_CRED credential map in the same script):
job_type
CLAUDE_ROLE
dev-issue, continue-issue
dev-coder
infra-task, infra-alert, nc-alert-batch
sys-admin
secret-manager
secret-manager
review-pr, review-plan
dev-reviewer
anything else
(empty)
Empty → no role. The generic worker-issue.md runs and behaviour is identical to
pre-#4705 dispatch. This is a designed state, not a fault.
REPO_CONTEXT_FILE resolves separately, by exact filename:
/opt/p24-infra/infra/repo-contexts/<repo-name>.md if that file exists, else empty.
When the scoped prompt is actually used
worker-issue-scoped.md replaces worker-issue.md only when all four hold:
a persona resolved (CLAUDE_ROLE non-empty), and
job_type ∈ {dev-issue, continue-issue, infra-alert} — review-plan is excluded on
purpose (Haiku-pinned skill path, not an implementation run), and
the currently selected prompt is exactly worker-issue.md — so worker-nextjs-dev.md,
worker-python-dev.md, worker-content.md, infra-task-request-worker.md,
review-pr-worker.md, worker-secret-manager.md and alert-triage-batch.md are never displaced,
and
worker-issue-scoped.mdexists on the host — a node with a stale /opt/p24-infra silently
keeps the generic prompt instead of being pointed at a missing file.
Capability scope — persona first, enforcement behind a flag
The capabilities: frontmatter (can / cannot / delegate_to) is machine-readable,
CI-validated for shape, and — since #4706 — partially machine-enforced.
Two layers, in order:
Persona (always on).worker-issue-scoped.md Step R2 tells the worker to refuse an
out-of-scope action and delegate rather than improvise. This is the only layer that covers
judgement scope (“perform a security audit”, “push directly to main”).
Spawn-time guards (ROLE_CAP_ENFORCE=1).spawn-worker.sh withholds the credential
material an out-of-scope action would need, so it fails closed instead of relying on
compliance. Covers the two mechanical cases below.
What the guards do
scripts/lib/role-capabilities.py parses the role’s capabilities.cannot and derives two facts:
Fact
Trigger in cannot prose
Effect when ROLE_CAP_ENFORCE=1
ROLE_DENY_SOPS
”decrypt”, or “touch any credential/SOPS”
SOPS_AGE_KEY_FILEpinned to a nonexistent path (/nonexistent/role-cap-withheld) + SOPS_AGE_KEY/SOPS_AGE_KEY_CMD/SOPS_AGE_SSH_PRIVATE_KEY_FILE cleared, SOPS_FILE cleared, role-*.env not sourced, scope-key block skipped
ROLE_DENY_SSH
”SSH into a … server”, “perform any server or Docker operation”
SSH_AUTH_SOCK unset, GIT_SSH_COMMAND=/bin/false
Resulting matrix — the seven non-ops roles are denied both; the three ops/credential roles keep both:
“Cannot WRITE SOPS” does not withhold the age key.sys-admin and sys-security both declare
cannot: Write to or re-encrypt any secrets/*.env.sops file alongside
can: READ credentials from SOPS for verification only. Denying their key would break a
documented capability, so only decrypt-level prohibitions deny. Write protection is branch
protection plus review, not a spawn-time guard. test_read_only_sops_roles_are_never_denied_the_age_key
pins this — it is the assertion most likely to be “fixed” wrongly by broadening the keyword rule.
Commit signing is untouched.ROLE_GIT_SIGNING_KEY / user.signingkey is an SSH key used to
sign commits, not to reach a server. dev-coder is SSH-denied but must still sign; the SSH guard
deliberately does not touch it.
Known gap
The SSH guard removes agent-based auth and fails git-over-SSH closed. It does not revoke read
access to an on-disk key under ~/.ssh — that key is owned by claude-runner and shared by every
worker on the host, so it cannot be withheld per-worker from spawn-worker.sh. Closing that
residual gap needs per-worker filesystem isolation (systemd-runPrivateTmp/BindPaths, or
per-role unix users) and is a separate piece of work. Today the guard raises the bar from “nothing
stops it” to “the convenient paths are gone and every attempt is logged”.
The SOPS age key has the same class of residual, on-disk gap. The env-var guard now fails closed
correctly (SOPS_AGE_KEY_FILE is pinned to a nonexistent path, not merely unset — see #6015 for
why unset alone let sops fall back to ~/.config/sops/age/keys.txt and silently defeat the
guard). But because the age key material still lives on disk under the worker’s own account, a
worker running as that OS user could in principle re-point sops at it. Two things address that,
neither owned by spawn-worker.sh:
The stray symlink must not exist.~/.config/sops/age/keys.txt on claude-runner was a
symlink to the universal per-host age key (created 2026-07-06 for interactive SOPS convenience,
predating the guard). Removing it — and sweeping every worker account/host for the same pattern —
is a server operation delegated to infra-task/sys-admin. test_no_worker_account_has_a_sops_age_key_symlink
in scripts/tests/test_worker_role_capabilities.py asserts its absence when run on a worker host
(it skips in CI, where the worker home dirs do not exist).
Full isolation is the same per-worker-filesystem-isolation work noted for the SSH key above.
Rollout
ROLE_CAP_ENFORCE defaults to 0, mirroring the SCOPE_KEY_ENFORCE=0 precedent in the same
script. spawn-worker.sh is the dispatch core for every queue job on bms-4 — the sole production
worker host (vps-i1 is monitoring-only, not a worker dispatch host, confirmed live 2026-08-11 in
#6085; lap1 and windows-dev are separate variable-uptime supplementary nodes) — so a mis-firing
guard is a fleet-wide dispatch outage, not a contained failure.
How the flag reaches the worker (#5688 — wiring gap closed). The lever is
ROLE_CAP_ENFORCE in /opt/p24-infra/bms-4/.env, which reaches the dispatcher via its systemd
unit’s EnvironmentFile. The dispatcher spawns workers with su -s /bin/bash {worker_user} -c '{env_exports}; exec spawn-worker.sh …', and su -c does not propagate the dispatcher’s own
environment — only variables explicitly listed in env_exports reach the worker. Before #5688
ROLE_CAP_ENFORCE was not in that list, so spawn-worker.sh always fell back to its
${ROLE_CAP_ENFORCE:-0} default and flipping the host-file flag changed nothing (the enforcement
machinery in scripts/lib/role-capabilities.py was ready but dead-wired). queue-dispatcher-loop.py
now appends ROLE_CAP_ENFORCE={os.environ.get('ROLE_CAP_ENFORCE', '0')} to env_exports (Option A
of the #5030 investigation), so bms-4/.env is now the real, safely-defaulted (0) lever. This is
plumbing only — no behaviour change until the flag is explicitly flipped per the staged rollout
below (a separate step, plan-4815 §5).
Default (0) — facts are derived and logged as [role-cap] DRY-RUN …; nothing is withheld.
Read the dry-run lines from real spawns of each job type and confirm the deny facts match the
matrix above.
Flip to 1 per host. Failure mode becomes fail-closed: a missing helper or an unparseable
role file aborts the spawn rather than silently starting an unguarded worker.
Relationship to plan-4556 — the two flags compose, they do not compete:
ROLE_CAP_ENFORCE decides whether a role may hold a SOPS key at all.
SCOPE_KEY_ENFORCE decides which scope’s key it gets, if it may hold one.
The role gate is the outer decision and runs first; when it denies SOPS, the scope-key block is
skipped entirely, so SCOPE_KEY_ENFORCE=1 cannot re-grant a key the role gate withheld.
Delegation reuses machinery that already exists — it does not add a second path:
Out-of-scope need
Route
Server operations
worker-issue.mdStep 0b-ROLE — insert an infra-task queue row (server_preference='bms-4'), mark own row done
Credential operations
docs/playbooks/secret-manager-request.md — open a request issue for the secret-manager job type
Implementation (from a review/test role)
report it; do not fix inline
Anything else
comment naming the required role, add human-action, stop
Delegation stays the primary path even under enforcement: the guards make an out-of-scope action
fail, they do not make it succeed elsewhere. A worker that needs a credential or a server still
routes through the table above.
Changing things
Edit a role’s capabilities
Edit the frontmatter in .claude/agent-prompts/roles/<role>.md.
PR → merge to main. sync-claude-skills.yml deploys it.
delegate_to targets must name an existing role file — the test enforces this, because a typo’d
target is invisible at runtime.
Add a role
Create .claude/agent-prompts/roles/<name>.md with the same frontmatter shape. If it should own a
job_type, add it to job_typesand to the CLAUDE_ROLE case statement in spawn-worker.sh —
the test compares the two and fails on drift. Add the companion /role-<name> skill if the role is
also meant for interactive use.
Add a per-repo context
Create infra/repo-contexts/<repo-name>.md (exact case — Art-Agency.md, not art-agency.md) and
merge. No script change: resolution is by filename existence. See that directory’s README.md.
Context files are additive only — they may tighten a standard, never relax one from
worker-issue.md, CLAUDE.md, or the target repo’s own CLAUDE.md. Stricter rule wins. Never put
a secret value in one; key NAMES and SOPS paths only.
Verifying on a host
# Which prompt would a dev-issue spawn use?ls -l /opt/p24-infra/infra/agent-prompts/worker-issue-scoped.md # present => scoped path activels -l /opt/p24-infra/infra/repo-contexts/# What the spawner logged for a given issue:grep 'role-scope' /var/log/p24-infra-workers/dev-issue-<N>.log# -> [role-scope] CLAUDE_ROLE=dev-coder repo_context=<none># -> [role-scope] using role-scoped prompt for role=dev-coder
Absence of the second line means the generic prompt was used — check the four conditions above.
Rollback
Effect is graded, so pick the smallest step that stops the bleeding.
Disable scoping on one host, immediately, no deploy — remove the wrapper; the existence
guard takes over and every worker reverts to worker-issue.md:
The next git reset --hard deploy restores it, so this is a stopgap, not a fix.
Disable fleet-wide — revert the sync-worker-roles.yml + spawn-worker.sh commit and let
deploy-vps-scripts.yml propagate. CLAUDE_ROLE becomes unset and the pre-#4705 path runs.
Role content only — revert the role file; sync-claude-skills.yml redeploys it.
No database state is involved: #4705 deliberately added no assigned_role column and no migration
(design Q3), so there is nothing to unwind in Supabase.