Worker role scoping — operations

Status: live · Implemented by: #4705 · Design source: #2075 Owning role: sys-admin (deploy/rollback) · dev-coder (role + context content)

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.

VariableSet byMeansValues
ROLEspawn-worker.sh positional arg 7 (#1706)dispatch routing class — picks which agent prompt file to loadinfra | dev | orchestrator | ""
P24_CLAUDE_ROLE~/.bashrc per machine (_setup-claude-env-ansible.sh)machine standards profile — which standards/ docs a session loadsmonitoring-ops | saas-dev | db-admin | orchestrator
CLAUDE_ROLEspawn-worker.sh (derived, #4705)worker persona + capability scopea role filename stem, e.g. dev-coder, sys-admin

CLAUDE_ROLE is the only one this document is about.


Where things live

AssetRepo pathDeployed toDeployed by
Role definitions (+ capabilities: frontmatter).claude/agent-prompts/roles/*.md~/.claude/agent-prompts/roles/ on vps-i1, vps-h1, bms-4sync-claude-skills.yml
Role-scoped worker promptinfra/agent-prompts/worker-issue-scoped.md/opt/p24-infra/infra/agent-prompts/ on vps-i1, bms-4sync-worker-roles.yml
Per-repo context filesinfra/repo-contexts/*.md/opt/p24-infra/infra/repo-contexts/ on vps-i1, bms-4sync-worker-roles.yml
Resolution logicscripts/spawn-worker.sh/opt/p24-infra/scripts/deploy-vps-scripts.yml + sync-worker-roles.yml
Shape validationscripts/tests/test_worker_role_capabilities.pyCI 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:

  1. Explicit CLAUDE_ROLE already in the environment — escape hatch for manual invocation.

  2. Job type, via this map (mirrored from the ROLE_CRED credential map in the same script):

    job_typeCLAUDE_ROLE
    dev-issue, continue-issuedev-coder
    infra-task, infra-alert, nc-alert-batchsys-admin
    secret-managersecret-manager
    review-pr, review-plandev-reviewer
    anything else(empty)
  3. 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.md exists 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:

  1. 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”).
  2. 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:

FactTrigger in cannot proseEffect when ROLE_CAP_ENFORCE=1
ROLE_DENY_SOPS”decrypt”, or “touch any credential/SOPS”SOPS_AGE_KEY_FILE pinned 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:

Denied SOPS + SSHRetains SOPS + SSH
dev-coder, dev-reviewer, dev-tester, content-creator, designer, marketing-director, video-producersecret-manager, sys-admin, sys-security

“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-run PrivateTmp/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).

  1. Default (0) — facts are derived and logged as [role-cap] DRY-RUN …; nothing is withheld.
  2. Read the dry-run lines from real spawns of each job type and confirm the deny facts match the matrix above.
  3. 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 needRoute
Server operationsworker-issue.md Step 0b-ROLE — insert an infra-task queue row (server_preference='bms-4'), mark own row done
Credential operationsdocs/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 elsecomment 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

  1. Edit the frontmatter in .claude/agent-prompts/roles/<role>.md.
  2. python -m pytest scripts/tests/test_worker_role_capabilities.py -v
  3. 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_types and 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 active
ls -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.

  1. Disable scoping on one host, immediately, no deploy — remove the wrapper; the existence guard takes over and every worker reverts to worker-issue.md:
    mv /opt/p24-infra/infra/agent-prompts/worker-issue-scoped.md /root/worker-issue-scoped.md.bak
    The next git reset --hard deploy restores it, so this is a stopgap, not a fix.
  2. 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.
  3. 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.