The single source of truth for the model accounts (Anthropic / OpenAI) that back automated coding workers across my codebases. This repo is public so its GitHub Actions run on free unlimited minutes. Token VALUES never live in the repo — each account's token is an encrypted GitHub secret (masked in logs, blocked from fork PRs); account emails / PII are not published (redacted from issues; the private handle→email map lives only in a maintainer secret + gist). Account handles, limits, live-usage probing, and the selection logic ARE public — they carry no secrets. Read-only to non-collaborators; only maintainer/bot-triggered workflows touch secrets.
A worker (a GitHub Actions job in some codebase, e.g. sparq-org/sparq) asks this registry for an
account to use; the registry applies per-account limits, a cross-codebase concurrency lock, model
fallback chains, and prompt-cache affinity, and hands back a claim. When the worker finishes it
releases the claim.
This README is the reference for what the registry does. AGENTS.md is how you
work ON it — the author-side pre-flight checklist an agent runs on its own diff before reporting
done, defined there once and pointed at from the role briefs in .claude/agents/.
scripts/selftest-suite.txt is the authoritative self-test manifest and is checked in both
directions against scripts that advertise a self-test entrypoint. Enroll a new self-testing script
in that manifest in the same PR. Retiring a script or manifest entry requires two PRs: first add its
filename to scripts/selftest-retirements.txt on the base branch, then remove the script and its
manifest entry in a later PR. The gate refuses an unapproved same-PR retirement.
Each model account is a GitHub issue in this repo. The issue body is structured YAML front-matter (no secrets):
provider: anthropic # anthropic | openai
harness: claude # claude | codex
credential_format: claude-oauth-token
models: [opus5, fable, opus, sonnet, haiku] # or [sol, luna, terra] for openai; enables model-fallback routing
tier:
weekly_limit: "..." # human note of the plan's weekly cap
five_hour_limit: "..." # the rolling 5h window cap
reset_schedule: "..." # when the windows reset (per-account; they differ)
max_concurrent_workers: 4 # how many workers may run on this account at once
# PER-PROVIDER default: openai 12, anthropic 4 — see below
secret_ref: ACCT_<HANDLE>_TOKEN # the NAME of the GitHub secret holding this account's token
notes: "..."max_concurrent_workers is per-provider — never hand-write 1. The maintainer-stated plan
parallelism is openai 12, anthropic 4 (#278);
the set-up-account broker derives it from the provider alone, so a brokered account of either kind
already lands at its provider's value. The field is a per-account operator restriction, not a
safety floor — global capacity is bounded by the lease ledger and by policy/repos.toml's
max_concurrent, so narrowing one account only narrows that account. Minting 1 by hand reproduces
the fleet-capacity defect #278 fixed: choose_account's cap gate then refuses every further claim
against an account that has spare capacity. Note the two different fallbacks in
select-and-claim._parse_account: an absent key defaults to 4, but a present, non-numeric
value (including an empty one) silently becomes 1 rather than raising — so either omit the key or
give it a plain integer.
The token value for each account is stored ONLY as a secret in this repo's
dispatch-secrets environment, named by secret_ref — never at repository/organization
scope (issue #101: the dispatch secrets-guard fails closed while ANY repo-scope secret exists),
never in the issue body, never in a comment, never in a public repo.
A GPT-5.6 review showed that reaction-counting cannot be a mutex — GitHub allows only one reaction of a given type per identity, so many same-bot workers all see one 🚀 and all believe they own a slot. Replaced with a compare-and-swap lease ledger (
scripts/select-and-claim.py).
A single JSON ledger data/leases.json records every active lease. It lives on the
dedicated ledger data-plane branch — not on master — so branch protection on the
code branch never rejects the bot's contents-API writes, and a token that can only write
ledger can never push code (issue #28; data/README.md on master is the tombstone):
{"leases": [{"account": "acct01", "claim_id": "<uuid>", "holder": "<owner/repo@run>",
"package": "sparq-core", "role": "impl", "model": "sol",
"issued_at": 0, "expires_at": 0}]}Claim = a compare-and-swap: read the file and its blob SHA, reclaim expired leases, and if an
eligible account (serving a model in the requested chain, under max_concurrent_workers,
cache-affinity-preferred) has a free slot, append a lease with a unique claim_id + expires_at,
then PUT the file with the read SHA. A concurrent writer changed the SHA → the PUT is rejected
(409) → retry. Because every codebase CAS-updates the same ledger, capacity is enforced globally
without reaction counting. Release and heartbeat are keyed by the unique claim_id
(idempotent). The groomer (groom-leases, every 15 min) reclaims leases past expires_at, so a
dead/cancelled worker frees its slot automatically — no receipt-guessing.
That reclaim is not blind (issue #35). A lease whose holder records a still-active worker /
review-fix run has its expires_at renewed instead, and renewed before it lapses rather than
after, so a legitimately long run — the worker lease's TTL covers only the agent job, while the
publish and review-prep jobs that follow it have their own timeouts — never has its slot handed to
a second worker mid-run. It has to move expires_at rather than merely keep the row, because every
duplicate-suppression consumer (reclaim_expired, partition_available, dispatch's
_live_holder_keys / sibling_lease_conflict) reads that field, not the row's presence. A
dead/absent run, a run outside the two lease-holding workflows, a holder with no run id (the
TTL-managed review:/fix: repair leases), and any lease older than the 6-hour renewal ceiling
are all still reclaimed — the ceiling is what stops a run wedged in in_progress from trading the
double-dispatch for a permanent capacity leak.
When the Actions API does not answer at all (403/5xx/garbage body), the ownership decision is deferred to the next tick — and the row is held on a short 30-minute grace deadline while it waits. Deferring by leaving the expired row untouched would defer nothing: by the paragraph above an expired row suppresses nothing, so one transient probe failure would hand the account and the holder key straight to the next dispatcher while the original run may still be writing. The grace is shorter than a proven-live renewal, keeps the row inside its lead window so every tick re-probes it, and never outruns the same 6-hour ceiling — a probe that never recovers still cannot pin a slot.
A row's package is the conflict partition its lease reserves, reduced from the source issue's
area:* labels. A partition key names a SET of areas, and two rows exclude each other iff their
sets intersect (lease_schema.packages_conflict):
the row's area:* labels |
the key it reserves | excludes against |
|---|---|---|
exactly one, area:A |
A |
anything reserving A |
two or more, area:A + area:B |
A,B (sorted, ,-joined — canonical) |
anything reserving A or B |
| none | __global__ |
everything (fail-closed: the footprint is unknown) |
The zero-area row is the only one that is still deliberately over-wide, and that asymmetry is the
whole safety argument: an unlabelled row's blast radius cannot be named, so narrowing it would make
it concurrent with all live work at once. Multi-area rows used to reserve __global__ too — which
made "touches A and B" mean "touches everything" and self-blocked 13.9% of the ready board — and
that is what the set-valued key fixes.
__global__ is the universal set, so packages_conflict serializes it in both directions exactly
as the old string comparison did. Every enforcement site decides with that one predicate —
select-and-claim.partition_available (the CAS claim), dispatch-claim.filter_busy_area_items
(PLAN assemble), revalidate_items_against_live_pulls (CLAIM live re-check) and
sibling_lease_conflict (the cross-lane ledger view) — because a widening applied at one site and
not another plans work the next one refuses, every tick.
ci and docs are cross-cutting: an open worker PR that touches them is almost never in conflict
with another one. They stay valid candidate keys — a row declaring area:ci is derived, leased,
routed and counted exactly as before — but an occupant holding one no longer defers a row that
declares it. The declaration, its measured basis and its fail-safe live in ONE place,
dispatch-claim.NON_RESERVING_PARTITIONS, and nothing else in the file knows the names.
Measured over the busy-occupancy population this leg actually reads, counting holder pairs that
share at least one changed file: ci 0/15 pairs and docs 0/10 (and over the wider area:-labelled
open-PR population, 40/435 = 9.2% and 30/630 = 4.8%) — against deps 21/21 = 100%, every pair on
Cargo.lock, and 57.1% for the crate areas. deps and the crate areas therefore still reserve, and
__global__ can never be exempted: non_reserving_partitions() validates the declaration through
package_areas itself and voids the whole set — never a part of it — for anything malformed,
degrading to today's fully-reserving behaviour.
The exemption matches an exact partition atom, never a prefix: ci-fragments is a separate atom
that never conflicts with ci under packages_conflict, so it keeps reserving. sparq#4928 does the
opposite for a reason that does not apply here — its keys_conflict is longest-ancestor containment,
where ci-fragments does conflict with ci.
This is the occupancy half of sparq-org/sparq#4928, which does the same thing to the other occupancy leg (the target's own readiness engine, which decides what PLAN offers). Both are needed: measured on a live snapshot, #4928 alone widened PLAN's sparq frontier from 4 rows to 6 and this leg then re-deferred both added rows.
That reduction is lease_schema.plan_package. Precisely:
| deriver | how |
|---|---|
dispatch-claim.plan_package (mints the CAS claim) |
delegates to lease_schema.plan_package |
review-fix.yml resolve job |
calls dispatch_claim.plan_package — the minter's own function object |
worker.yml self-claim shell step |
calls lease_schema.py --plan-package (the CSV CLI) |
worker.yml adopt validator |
imports lease_schema from the registry checkout |
dispatch-plan.py:_plan_package |
the ONE genuine copy — it ships inside the target repos, which have no lease_schema.py, so it is pinned by an executed agreement assertion in dispatch-claim.py --self-test, not shared |
So there is one canonical definition, four callers, and exactly one pinned copy. Reverting any of the
four callers to a private reduction — even one that agrees today — turns
dispatch-claim.py --self-test red, because every caller carries a shared-code leg and not just
a value-agreement leg (value agreement is exactly what a private-but-agreeing copy also satisfies):
- the three workflow callers are executed out of the parsed YAML, and must both agree on every
area shape and still contain the canonical call — re-inlining the rule removes the anchor, so
the extraction fails with
found 0instead of silently testing a copy; - the minter's delegation is probed directly: the self-test swaps
lease_schema.plan_packageout for a sentinel and requiresdispatch-claim.plan_package's result to follow it, which only shared code can do (adopt-loop L1b).
The fifth row, dispatch-plan.py, is the exception and is agreement-pinned, not shared — it
ships where lease_schema.py does not exist. A private copy there is caught only if it disagrees
on one of the exercised area shapes, which is weaker than the four callers above.
And it is the one seam that genuinely straddles two repositories: PLAN runs the target repo's
own dispatch-plan.py, cloned at run time, so a target still on the pre-set reduction mints
__global__ for its multi-area rows. The registry therefore does not trust the minted string —
dispatch-claim.item_partition re-derives every impl row's reservation from that row's own
(schema-validated) area:* labels, and _route_matches accepts the legacy __global__ spelling
only where those labels prove two or more distinct areas. A zero-area row reduces to
__global__ on both sides, matches the equality directly, and can never be narrowed by that rule.
The routing tables review-fix.yml re-derives inline (review_chain / fix_chain / ladders) are
pinned to dispatch-claim.REVIEW_CHAIN / FIX_CHAIN / worker-pr.ESCALATION_LADDERS the same way.
FIX_CHAIN is the provider-wide walk, not the chain the dispatcher claims from: since #578 the
fix lane claims FIX_CHAIN[impl_provider] ∩ route.model_chain, where the route is re-derived live
from the source issue's labels, so a trust-surface PR is never fixed by a tier its route
excluded. That narrowing reaches the workflow through the existing model_pin input — which is what
keeps the two derivations equal; an intersection the pin cannot express fails the claim closed.
This matters because these values are derived twice by design — once by the dispatcher that
mints a CAS claim, once by the review/fix (or worker) run that adopts it — and both adopt steps
compare the two for equality. When resolve carried its own pre-#112 alphabetically-first
reduction, every PR whose source issue held two area:* labels had its own dispatcher's claim
rejected on every tick, forever: a deterministic loop that burned an account lease and a runner per
tick and was the single largest failure class on the review lane that day.
scripts/select-and-claim.py (added in Phase 3) takes (package, role, model-chain) and returns an
opaque claim (which secret to use) or none-free:
- Walk the model fallback chain (e.g.
sol → opus5 → fable) to the first provider/model with a non-full, non-reset-exhausted account. - Among eligible accounts, prefer the one with prompt-cache affinity — most recently used for the
same
package+rolewithin the provider's cache window (Anthropic prompt cache ≈ 5-min TTL), to keep the cache warm; avoid interleaving unrelated work onto a warm account. - Atomically claim it (add 🚀, then recount to resolve the check-then-claim race — if the recount
exceeds the cap, back off and remove the reaction), write the receipt, return the
secret_ref.
Which skills/roles/packages ran recently on each account is tracked here (as receipt comments +
a rolling data/cache-affinity.json), never in the public repos.
🤖 Maintainer decision (2026-07-17), recorded by a SPARQ agent.
-
UI/front-end surfaces route to the openai/codex model chain (original-builder ownership: GPT-5.6 built the registry dashboard,
e4098b9). Repos onboarded to the registry inherit this default. Machine-readable form: therole = "site"route (model_chain = ["sol", "opus5", "fable", "opus"]— sol-led; opus5 primary Anthropic tier since 2026-07-24; terra/sonnet are docs-only, 2026-07-18) in this repo'sorchestration/routing.toml; when onboarding a new target repo inpolicy/repos.toml, mirror that route into the target's own routing table (sparq-org/sparqalready carries it).scripts/triage.pyderivesrole:sitefrom the exact UI-surface labels (area:dashboard,dashboard,surface:frontend). Implement it as a ROLE route, never amatch_labelsrule — the arm-side security classifier unions allmatch_labelskeywords, so UI keywords there would security-classify every UI PR (post-Decision-7 revision: an audit trail, not a park). -
Frontier-tier agents author ALL CI/infrastructure work (maintainer decision 2026-07-17): GPT-5.6 sol (openai; alias
sol) or the Anthropic frontier tier (opus5primary since 2026-07-24,fableas its tail fallback) — explicitly including the self-draining pipeline infrastructure itself (dispatch, workers, gate aggregators,.github/workflows, orchestration scripts). Cheaper tiers (sonnet/haiku) no longer author infra, and terra/sonnet are docs-only (2026-07-18); cross-provider review is unchanged (whichever provider's frontier writes, the other reviews). Machine-readable form: therole = "ci"route (model_chain = ["sol", "opus5", "fable"]) in this repo'sorchestration/routing.toml; mirror a frontier-only ci chain into each onboarded target's routing table (sparq-org/sparqcarries it, sparq PR #3422).scripts/triage.pyderivesrole:cifrom the exact infra-surface labels (area:ci,area:workflows). The chain is frontier-ONLY rather than floor-pinned: the routing schema has no floor/pin field, and chain exhaustion at the claim step already defers the item (retried next tick, defer-not-fallback) instead of degrading tier — deliberately notescalate = true, which would flip a starved item toneeds:user. Where an infra surface is also a trust surface (dispatch/worker/set-up-account/review-loop/groom), the securitymatch_labelsoverride still wins (opus + trust-surface audit; Decision 7 revised 2026-07-18) — stricter than the frontier floor, unchanged. -
A chain-ORDER preference belongs in the target's routing table, never in one repository's resolver. Every dispatchable route is derived twice: PLAN runs the target's
scripts/route-resolve.py, CLAIM re-derives it with registry-ownedscripts/policy-resolve.pyagainst the target's routing table read at its protected default tip, anddispatch-claim._route_matchesrequires exact equality ofmodel_chain/agent/escalate. A rule only one side implements is therefore not a preference that half-applies — it raisesRouteDivergenceError, the item defersroute-plan-claim-divergence, and because the comparison is a pure function of the labels and the table it defers identically on every subsequent tick. The affected issues stop dispatching entirely. Machine-readable form —orchestration/routing.tomlin the target:[[chain_preference]] labels = ["area:gui"] # EXACT labels; ANY one selects. Never a substring: "gui" in label # would sweep area:guide / area:guidance into the carve-out. lead = "sol" # moved to the FRONT; every other rung keeps its relative order, # so this is PREFERENCE, NOT EXCLUSION. requires = ["sol", "opus5"] # fires only when the chain ALREADY contains all of these.
scripts/chain_preference.pyis the shared mechanism, imported by both registry resolvers and hard-coding no selector.leadmust appear inrequires, which is what makes re-order the only reachable effect — a preference can never inject a model into a chain that deliberately excludes it (e.g. turning the single-provider, escalatingrole:researchchain cross-provider). Securitymatch_labelsroutes are exempt on both sides: an implementor preference must never re-order a soundness chain. Verify a target withpython3 scripts/cross-resolver-agreement.py --target-root <checkout>, which drives both resolvers over a 22-row label matrix and reports any row they decide differently. Live instance: the maintainer's 2026-07-26area:guicarve-out (sparq PR #4211).
Goal: make one more model account usable by the workers. There are five required steps; the account is invisible to the selector until all five are done (notably the
account_pooledit — a common miss). Every command targets the registry repojeswr/agent-account-registry— which is public (see the header), so treat everything you write there as world-readable. Never print a token value into chat, a log, an issue, or a commit.
Naming convention. Handle = acctNN (e.g. acct05). Its token secret is
ACCTNN_TOKEN (the handle upper-cased + _TOKEN, e.g. ACCT05_TOKEN). The account issue's
secret_ref: field MUST equal that secret name.
Slot claim (REQUIRED before any write). Slot numbers are allocated through the
refs/acct-claims/ ref namespace — the canonical allocation record that EVERY account writer
(the set-up-account broker and this manual runbook alike) must claim in before touching a
secret or an issue. Ref creation is first-writer-wins on the server, so exactly one writer can
ever own a number. That ref IS the mutual exclusion (#237): the secrets API is an upsert with no
conditional create, so without it two writers running at the same moment — a manual enrolment
racing the broker — both read the same maximum, both pick max+1, and the second silently
overwrites the first's live credential.
Deriving NN: a successful claim does NOT prove the slot is free. The namespace was
introduced after the first accounts existed and is never backfilled, so every legacy slot — and
anything written out-of-band, skipping this protocol — occupies a number with no claim ref
behind it. Pick NN the way the broker's store step does, as one past the maximum of the
COMPLETE union of all four allocation records: claim refs, acctNN issue titles in ANY state
(closed and retired numbers stay permanently reserved — never recycle one), and ACCTNN_TOKEN
secret names at BOTH the repository scope and the dispatch-secrets environment (post-#101 the
environment is the canonical home, so a repo-scope-only look is blind to every live token).
Every listing is --paginate, because a capped page silently reports an occupied slot as free;
if any of the four fails, freeness cannot be proven — STOP rather than guess:
REPO=jeswr/agent-account-registry
{ gh api --paginate "repos/$REPO/git/matching-refs/acct-claims/" \
--jq '.[].ref | select(test("^refs/acct-claims/acct[0-9]+$")) | ltrimstr("refs/acct-claims/acct")'
gh api --paginate "repos/$REPO/issues?state=all&per_page=100" \
--jq '.[] | select(has("pull_request") | not) | .title | select(test("^acct[0-9]+$")) | ltrimstr("acct")'
gh api --paginate "repos/$REPO/actions/secrets?per_page=100" \
--jq '.secrets[].name | select(test("^ACCT[0-9]+_TOKEN$")) | ltrimstr("ACCT") | rtrimstr("_TOKEN")'
gh api --paginate "repos/$REPO/environments/dispatch-secrets/secrets?per_page=100" \
--jq '.secrets[].name | select(test("^ACCT[0-9]+_TOKEN$")) | ltrimstr("ACCT") | rtrimstr("_TOKEN")'
} | sort -n | tail -1 # NN = this number + 1 (zero-padded to two digits)Then claim it — and only then write anything:
gh api repos/jeswr/agent-account-registry/git/refs \
-f ref='refs/acct-claims/acct05' \
-f sha="$(gh api repos/jeswr/agent-account-registry/commits/master --jq .sha)"If this fails with Reference already exists, the number is taken — bump NN and retry. Any
other failure leaves the claim state unknown: STOP. Never delete a claim ref: a claimed-but-unused
slot is merely burned (safe), while reusing a number can silently overwrite a live credential
(gh secret set is an upsert) or mint a duplicate issue title (GitHub does not enforce unique
titles).
Burned slots are counted, never reclaimed (#245). Because a failed enrolment keeps its claim,
burned slots used to be invisible. Every groom sweep now prints an ORPHAN-CLAIMS line naming
each refs/acct-claims/acctNN that has no acctNN issue and no ACCTNN_TOKEN secret, so a
runaway broker shows up as a rising burned= count instead of silently eating numbers. It is
REPORT-ONLY and deliberately has no cleanup mode: deleting a claim ref re-opens the overwrite race
above. A slot that carries a secret but no issue is NOT reported — that is a stored credential
whose enrolment died before registration, and it needs the account issue written, not a cleanup.
- Anthropic (Claude models): run
claude setup-tokenwhile logged into the target account. It prints a long-livedsk-ant-oat…token (credential_format: claude-oauth-token). Do NOT copy~/.claude/.credentials.json— that subscription blob's refresh token rotates and dies the moment the interactive session refreshes (this broke the canary once). If you prefer a Console API key, that also works:credential_format: anthropic-api-key(value is thesk-ant-api…key). - OpenAI (codex/GPT models): the codex CLI OAuth from
~/.codex/auth.json(credential_format: codex-auth-json). Its access token expires and its refresh token is ONE-TIME-USE with server-side replay detection (refresh_token_reused), so the worker refreshes it HOST-SIDE before the model container starts and writes the rotated credential back to the secret (issue #596; the container only ever sees a fresh access token, never the refresh token). Two operational consequences:- Give the registry its OWN codex login. Refresh chains are per-authorization, not per-account.
If the stored secret and an interactive
~/.codexlogin share one chain, whichever refreshes second is killed withrefresh_token_reused— so run the device-code login once for the registry and enrol that credential rather than copying a box's live~/.codex/auth.json. REGISTRY_SECRETS_PATmust be present for the account to self-heal indefinitely. Without it the write-back warns and skips, the rotated refresh token is lost, and the account needs a re-mint the next time its access token expires.- The write-back is reachable from the pre-flight's FAILURE path. The exchange consumes the
one-time-use grant early inside the credential-prepare step, so the write-back step is keyed to
always()plus the account selection — never to that step succeeding. Any later failure in prepare (the no-leak assertion, the tamper baseline, the pinned CLI install, the step-output export) would otherwise discard a grant the provider had already rotated, leaving the account permanently unable to authenticate.worker-prep.shwrites the durable material, the credential format, and the rotation marker at the moment the rotation happens, so a write-back reached with no mount and no exported paths still knows what to persist and how to validate it;dispatch-secrets-guard.rotation_writeback_reachable_verdictasserts that reachability in both worker lanes, and the obligation is universal, not existential: the step'sif:must evaluate TRUE on every path where a rotation may already have happened, and may reference only facts settled before the pre-flight (always(), the dry-run input, the claim outputs, the account selection step). Anything else — the prepare step's own outcome, the model step's outcome, a prepare output,success()— is refused by name. An existential "there is some world where it runs" check accepted all of those, which is how a model-step guard could have been reintroduced. Each lane's call site is also required to be a reachable command, so commenting therun:out is a red tick rather than a silently discarded grant. - A grant is re-sent only when the previous attempt provably did not deliver it.
_post_token_endpointdrives the exchange throughhttp.clientwith an explicitconnect(), so the pre-send / post-send boundary is a property of the code's STRUCTURE rather than a guess from the exception's type (ssl.SSLErroris anOSErrorraised from both the handshake and the response read, and urllib'sURLErrorconflates connect with send — no type test can be sound in both directions). A fault raised strictly before the request write — malformed URL, DNS failure, refused connection, connect timeout, failed TLS handshake — isSTATUS_NOT_SENTand keeps its bounded retry; a fault raised at or after the write — send/response timeout, reset, broken pipe, a TLS fault fromresponse.read(), a truncated body — isSTATUS_INDETERMINATE, classedcredential-remint-required, and raises on the spot. The two questions are deliberately separate.classify_refresh_failureanswers "is a LATER attempt worth making?", which is why 429 and 5xx staycredential-refresh-transient: a later attempt re-reads the stored secret, and a grant the provider did consume then fails closed withinvalid_grant._RESEND_SAFE_STATUSESanswers the narrower "may THIS run POST the same grant again?" and admits onlySTATUS_NOT_SENTplus a documented 429 throttle. A 2xx with an unusable body (truncated, unparseable, or missingaccess_token) is the strongest available evidence that the rotation did commit, so it raises instead of retrying. - The remint class carries two causes, and the operator message says so.
worker-prep.sh's::error::line forcredential-remint-requiredmust not assert the grant "is dead": the class covers both a provider-confirmed dead grant and an indeterminate outcome whose fate is unknown and which was deliberately not re-sent. - The credential never becomes job-wide environment.
worker-prep.shemits the isolatedHOME, the materialized credential path and its rotation baseline as step outputs, not as$GITHUB_ENV(#232). Each lane then routes them by hand to the only steps that need them — the model/review/fix run and the rotation write-back — so the policy gate, which executes the target's own build scripts and tests on that runner, never inherits a pointer to the account credential, the raw account handle, or aHOMEthe model itself could write.worker-live.sh's self-test asserts the routing on both live workflows and that a successful prepare writes nothing to$GITHUB_ENV. - ...and the credential FILES leave the runner before any target-controlled code runs. Routing
decides which steps are handed the path; it does not decide which steps can find it. GitHub
supplies
$RUNNER_TEMPto every step unconditionally, the credential is materialized under$RUNNER_TEMP/registry-worker, and the gate's build scripts run as the runner user that owns that mode-600 file — so both lanes callworker-live.sh purge-credentialsin analways()step ordered after the rotation write-back and before the rustup pin and the gate. It removes the isolatedHOMEand every host-side credential artifact, then re-scans for residue and dies if anything survived, so the gate's implicitsuccess()keeps the target's code off a runner that still holds the credential. Non-vacuous both ways: the self-test reads a real prepared tree through a gate-shaped reader that has only$RUNNER_TEMP(it finds the credential before the purge and nothing after), pins the purge/toolchain/gate ordering on both live workflows against a moved-late and a deleted mutant, and proves an un-removable credential fails the step closed.
- Give the registry its OWN codex login. Refresh chains are per-authorization, not per-account.
If the stored secret and an interactive
- On this work box, pre-provisioned Anthropic setup-tokens already exist as files
~/.claude-acctN-token(one per account). Read the file; do not echo it.
Secrets live in the dispatch-secrets environment, NOT at repo scope (issue #101): the
dispatch secrets-guard fails every tick closed while ANY repo-scope secret exists. If you
accidentally write one at repo scope, recover by deleting the stray directly
(gh secret delete <NAME> -R jeswr/agent-account-registry) and re-running the env-scoped
command below — never by re-running the migration workflow: post-cleanup it cannot mint
(the bootstrap repo copies are gone by design) and there is nothing left to migrate (see the
header of .github/workflows/migrate-secrets-to-env.yml).
Prove the secret is absent immediately before this write. The claim ref excludes every
protocol-observing writer, but not one that skipped the protocol, and gh secret set is an
upsert that would overwrite a live credential leaving no trace. Only an explicit HTTP 404 at
BOTH scopes proves absence — if either says the secret exists, or errors so absence is unproven,
STOP and re-derive NN (the broker's store step refuses on exactly this check):
gh api repos/jeswr/agent-account-registry/actions/secrets/ACCT05_TOKEN # must be HTTP 404
gh api repos/jeswr/agent-account-registry/environments/dispatch-secrets/secrets/ACCT05_TOKEN # must be HTTP 404tr -d '[:space:]' < ~/.claude-acct5-token | gh secret set ACCT05_TOKEN -R jeswr/agent-account-registry --env dispatch-secrets
# or from a value you already hold, without it hitting the shell history/ps:
# gh secret set ACCT05_TOKEN -R jeswr/agent-account-registry --env dispatch-secrets # then paste at the promptTOK="$(tr -d '[:space:]' < ~/.claude-acct5-token)"
curl -s -D - -o /dev/null -X POST https://api.anthropic.com/v1/messages \
-H "Authorization: Bearer $TOK" -H "anthropic-version: 2023-06-01" \
-H "anthropic-beta: oauth-2025-04-20" -H "content-type: application/json" \
-d '{"model":"claude-haiku-4-5","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}' \
| grep -iE 'HTTP/|anthropic-ratelimit-unified-(status|5h|7d)'Expect HTTP/2 200 and anthropic-ratelimit-unified-status: allowed. The
…-5h-utilization / …-5h-reset / …-7d-utilization / …-7d-reset headers are the live usage +
reset timestamps used for account prioritisation (see Usage-aware selection below).
read_accounts() parses these exact keys from the issue body. Title = the handle.
body='provider: anthropic
harness: claude
credential_format: claude-oauth-token
email: "<the account login email — a setup-token CANNOT introspect it (403 on /api/oauth/profile); fill from the account you logged in as>"
models: [opus5, fable, opus, sonnet, haiku]
max_concurrent_workers: 4
secret_ref: ACCT05_TOKEN
notes: "claude setup-token (long-lived, non-rotating). [your-marker]"'
printf '%s\n' "$body" | python3 scripts/select-and-claim.py \
--validate-account-record --account-handle acct05
gh issue create -R jeswr/agent-account-registry --title "acct05" --label account --body "$body"For an OpenAI account: provider: openai, harness: codex, credential_format: codex-auth-json,
models: [sol, luna, terra] (the FULL codex alias set — select-and-claim.py gates on exact alias
membership, so a terra-only record would defer every sol/luna claim), secret_ref: ACCTNN_TOKEN,
max_concurrent_workers: 12.
The 4 / 12 above are the per-provider defaults the broker mints (#278) — a hand-created
account must carry its provider's value or it lands at a cap the brokered account beside it does not
have. Lower it deliberately only to restrict this one account; it is not a safety floor (see
One issue per account).
gh issue edit <ISSUE#> -R jeswr/agent-account-registry \
--add-label account --add-label status:available --add-label provider:anthropicselect-and-claim.py sets available = (has status:available label); without it the account is
silently skipped.
Edit policy/repos.toml for each target repo that should be allowed to use this account, and raise
max_concurrent if you want more simultaneous workers:
[repos."sparq-org/sparq"]
account_pool = ["acct01", "acct02", "acct04", "acct05"] # add the handle from the steps above
max_concurrent = 5 # optional: allow more parallelismTwo constraints the resolver enforces, so a pool that violates either is refused outright rather than failing later at claim time:
- Handles must be canonical — exactly
acct[0-9a-z]{2,}, no surrounding whitespace and no case variation." acct04"and"ACCT04"are both rejected. (A padded handle used to pass validation while evading the retirement check below, and downstreamselect-and-claimstrips before matching, so it became claimable and leaked its CAS lease to TTL.) - Retired handles can never reappear.
acct03andacct06are retired (accounts cancelled / expired 2026-07-25) and are refused bypolicy-resolve.RETIRED_ACCOUNTS. Their slot names stay permanently reserved —set-up-accountcountsacctNNissues in ANY state — so a new enrolment always gets a NEW handle, never a recycled one. Commit + push tomaster. An account that is available + in the catalog but not in a repo'saccount_poolwill never be claimed for that repo.
If the handle was enrolled by the broker, do NOT widen its grant here. An ordinary policy PR takes effect for claims the moment it merges, but it establishes no row-scoped
account-pool/<handle>provenance for the new row — which the broker requires per granted row — and it leaves the account issue'sgrant_targets:line disagreeing with policy, so that handle's resume andactivatepaths then refuse. Widen through the broker instead: seegrant_targets:— the recorded target set, and how to repair it.
gh secret list -R jeswr/agent-account-registry --env dispatch-secrets | grep ACCT # secret present (env scope)
gh secret list -R jeswr/agent-account-registry # repo scope must stay EMPTY
gh issue view <ISSUE#> -R jeswr/agent-account-registry --json labels # status:available + provider:*
grep account_pool policy/repos.toml # handle presentEmail note: a
claude setup-tokenis inference-scoped and returns 403 onhttps://api.anthropic.com/api/oauth/profile, so the account email cannot be derived from the token — record it from the login you used. (An interactive subscription OAuth token can read/api/oauth/profile, which returnsaccount.email, plan tier, andrate_limit_tier.)
Anthropic returns live usage + reset data as response headers on every /v1/messages call (so a
max_tokens:1 probe is enough, and it works with an inference-scoped setup-token — no separate usage
API, and /api/oauth/profile is 403 for setup-tokens). Key headers:
| Header | Meaning |
|---|---|
anthropic-ratelimit-unified-status |
allowed | throttled/rejected — is the account usable right now |
anthropic-ratelimit-unified-5h-utilization |
fraction (0–1) of the rolling 5-hour window consumed |
anthropic-ratelimit-unified-5h-reset |
Unix ts when the 5h window resets |
anthropic-ratelimit-unified-7d-utilization |
fraction of the weekly window consumed |
anthropic-ratelimit-unified-7d-reset |
Unix ts when the weekly window resets |
anthropic-ratelimit-unified-representative-claim |
which window is currently binding (five_hour/seven_day) |
anthropic-ratelimit-unified-7d_oi-utilization / …-7d_oi-reset |
[FABLE] the account's SEPARATE weekly claude-fable-5 sub-quota — distinct from 7d; an account can read 7d-utilization=0.1 yet have this near 1.0 |
Fable sub-quota — a whole-account probe is not enough. claude-fable-5 draws from its own weekly
premium bucket, surfaced as the …-7d_oi-* headers. Those headers appear only on a probe that is
model=claude-fable-5 and carries BOTH the Claude-Code user-agent (claude-cli/…) and the
You are Claude Code, … system prompt (the subscription-OAuth premium path) — a plain haiku/opus
probe never emits them (and a plain fable probe 429s). account-usage.py therefore does a second,
Claude-Code-shaped fable probe for fable-capable accounts and merges fable_ok + fable_7d_oi_util/reset
into the usage map; usage_eligible(u, margin, model="fable") then requires that bucket to have headroom
in addition to the whole-account 5h/7d windows. Fail-closed: a rejected/absent fable probe makes the
account ineligible for fable only — its base signal still admits it for non-fable models.
Prioritisation policy (wired into choose_account): among eligible accounts prefer status=allowed
with the soonest whole-account 7d_reset (use-it-or-lose-it). Accounts without that weekly reset
sort last while retaining the existing cache-affinity/load/handle order. The Fable 7d_oi bucket remains
an additional eligibility gate for Fable routes, but does not replace the fleet-wide drain-order signal.
Skip an account whose status is not allowed or whose utilisation leaves less than
usage_safety_margin headroom.
OpenAI exposes no API to observe a codex subscription's usage, so provider: openai accounts
are exempt from health/usage probing by maintainer decision
(issue #29): they are eligible
without usage data ({"exempt": true} in the usage map — the fail-closed require-usage arm
applies to anthropic accounts only) and are simply used until a run hits a rate limit. They
remain subject to max_concurrent_workers caps and leases, plus a reactive backoff derived
from the data/model-health.json records the worker/review outcome jobs already CAS-append:
- Signal (host-observable only): the worker harness's exit class (
rate-limit/session-limit) is derived from the CLI's own stderr +[error]-prefixed lines, never model-authored stdout. - Duration: the provider's machine-parseable reset hint (
try again in 20s,retry-after: 120) when present, else 15 min doubling per consecutive hit, capped at 5 h; a successful run resets the multiplier. - Enforcement:
account-usage.pyreads the ledger from theledgerbranch via the pinned contents API (the job's checkout is the default ref, whose seed file is empty) and stampsbackoff_untilonto the exempt entry;usage_eligibleexcludes the account until it expires;usage-alert.pysurfaces active backoffs (BACKED OFF) instead of flagging exempt accounts probe-missing. - Fail-open by design: an unreadable ledger or missing salt disables only the backoff (loud
::warning::), never the exemption — the backoff is an optimization and must not reintroduce fail-closed starvation.
Exemption is NOT reachability (issue #639).
Being exempt from the quota probe means no usage token is required; it never meant the account is
reachable, and reading it that way is what kept handing a credential-remint-required account
(#596 / alert #622) to the allocator every tick. Every exempt entry therefore carries a
three-valued reachability, derived by model-health.credential_states from the same 48 h health
window as the backoff:
| value | evidence | dispatch | public page |
|---|---|---|---|
live |
a success record in the window |
eligible | available |
unproven |
no decisive record (also: no salt, unreadable ledger) | eligible, bounded | available |
dead |
≥ CREDENTIAL_DEAD_MIN consecutive auth rejections, no later success |
ineligible | unavailable |
| absent / unrecognised | the producer never stated it | ineligible | unknown |
usage_eligible allowlists only {live, unproven}, so an unstated or unrecognised value fails
CLOSED; usage-alert.classify and the dashboard's _quota_state apply the same allowlist, so
monitoring, the public page and dispatch cannot disagree. dead carries no TTL — unlike the
#596 cooldown it is evidence, not a hold — and clears the instant a success is recorded, which is
why it does not overturn that decision for the interleaved failure pattern it was calibrated on.
unproven still admits because there is no independent liveness probe for an exempt provider
(account-whoami.yml is manual-dispatch and disabled on a public repo), so refusing it would
self-latch: no dispatch ⇒ no records ⇒ unproven forever. What it costs is bounded to
CREDENTIAL_DEAD_MIN trial dispatches per health window, after which the evidence turns dead.
prune preserves a dead run's tail against the retention cap and the absolute ceiling, so a flood
of unrelated records cannot silently readmit the account.
Health-window retention is time-based, not count-based (registry #699). prune retains
max(MAX_RECORDS, everything inside RETENTION_FLOOR_SECONDS) under an absolute ceiling. Under the
old count-only cap the wall-clock the window COVERED was MAX_RECORDS / record-rate, so a busier
fleet covered less time — and the aged-out park exit (#691), which requires the window to cover
SUSTAINED_HEALTH_SPAN_SECONDS, shut itself as soon as throughput rose. The time floor makes
coverage independent of the record rate up to the ceiling; above the ceiling the oldest
non-preserved records are evicted, coverage can fall back under the span and the exit closes
again — but prune emits a ::warning:: naming the condition, the binding bound and the coverage
it left, and dispatch-claim reports the shortfall once per window load so an under-covered ledger
is never mistaken for "the parks are not old enough yet". A live backoff is never evicted, by the
cap or by the ceiling.
The probe must PROVE its materialization (same issue). dispatch.yml's probe — the lane that
spends real capacity — now applies the ledgergate the dashboard lane got in #219/#612: the ACCT_*
materialization step carries no continue-on-error, the probe step parses the token subset
(a substring test accepts {"ACCT01_TOKEN":""} and a truncated {"ACCT01_TOKEN":), it records an
outcome sidecar (usage-probe.json, schema account-usage-probe/v1) that usage-alert.py reads
fail-closed, and the exempt branch runs after that proof — so an unusable subset yields an
empty usage map and the wholesale-outage alert actually fires, instead of a non-empty map of
exempt accounts nothing was measuring.
A model launch that dies on the worker account's credential or capacity — exit class auth,
rate-limit, session-limit, billing, or either of worker-prep.sh's host-side credential
pre-flight classes credential-remint-required / credential-refresh-transient — is a credential
outage, not a model decline. The model never read the task or the diff, so there is no judgment to
charge for.
The set is held by two locks with distinct scopes — stated precisely, because #629's "the class is closed" was an overstatement of the first one:
- The consumer lock, bidirectional between the two constants. A set-equality assertion in
worker-pr.py --self-testrequiresCREDENTIAL_OUTAGE_EXIT_CLASSESto equal every raw exit class whose fold target is one ofmodel-health.py's outage decision classes, so a class can never be non-chargeable on one side only (the drift that made the two pre-flight classes chargeable for the whole acct01 outage). Its non-vacuity anchor is a required-subset check, so a legitimate thirdcredential-*class does not misfire it. - The emitter lock, producer → consumer. The consumer lock alone says nothing about the
producers:
broker-refresh.pycould start emitting a new raw class with it still green (folding tounknown, and therefore CHARGED — fail-safe, but the same shape of drift).worker-pr._emitted_credential_exit_classesparsesbroker-refresh.pywithastand requires everyCLASS_* = "credential-…"constant it can emit to be a key of the fold map.
Neither lock claims the vocabulary is closed against a new emitter script this derivation does not read; what they close are the two directions that actually caused #604 and #614. Two consequences, both wired through machinery that already existed:
- No round or attempt is consumed. The review round marker (
worker-pr.py round-record) and the worker attempt receipt (worker-issue.py record-attempt) are both written before the model launches, for bounded-crash accounting. On a credential-outage exit the run now records a void for its own(round, run)/ run key (round-void/void-attempt, gated by the single shared predicateworker-pr.is_credential_outage), andcount_rounds/count_attemptssubtract it — sodecide_budgetand the deferred-retry budget see that no round happened.setupandunknownare deliberately still charged: an unattributable failure must keep exhausting the budget, or a deterministic crash loop becomes unbounded. - No ladder advances. The repeated-decline escalation (
DECLINE_ESCALATION_MIN) and the capped-account discriminator (NO_CHANGE_LIMIT_MIN) both key strictly onno_change, soauthrecords can never reach them — andmake_recordrefuses to attach the no-change evidence fields to anauthrecord at all.
After AUTH_COOLDOWN_MIN (2) consecutive auth outcomes for one account with no interleaved
success, model-health.auth_cooldowns puts that account into a bounded credential cooldown —
delivered through the same account_backoffs → backoff_until overlay described above, for
AUTH_COOLDOWN_SECONDS (15 min, single-step, never doubling) — and raises the ops-alert
condition account-auth-cooldown, naming the salted fingerprint only plus the required
maintainer action (re-mint the setup-token). It is deliberately a cooldown, not a disable: the
account may be the fleet's only cross-provider review account, and zero reviews is worse than a
partial success rate.
Measured 2026-07-26 over 196 completed worker.yml runs: 70 success / 126 failure, dominated by
the structured exit class no_change — the model ran to a clean exit and produced no diff. The
same hard issue was then retried up to 3x with the same model, with no record of why the previous
attempt produced nothing, so one issue could consume three worker slots and three account leases to
produce nothing. Every sampled target was a substantive, correctly-labelled, genuinely hard task, so
this is a task-difficulty ↔ execution-mode mismatch, not a stale backlog.
Two halves, both in scripts/no_change_routing.py (the single declaration; worker-live.sh produces,
model-health.py stores, dispatch-claim.py routes):
why_no_diff. A worker returning no edits is asked, in the stable prompt prefix, to write.worker-no-diff.jsonnaming one of a closed five-word vocabulary (underspecified,blocked_on_decision,too_large,already_done,other). The file is lifted out of the tree before the change detection — otherwise writing the explanation would itself be the diff — and the reason rides the existing sanitizedno-change-v1envelope as its vocabulary index, never as text, so nothing model-authored can reach the public ledger or an alert body. Absent, malformed, or out-of-vocabulary ⇒unspecified, which is the value the router treats as no signal.- The decision, taken in
dispatch()on the deferred-retry path beforeallocator.claim()picks a model (the claim is what would otherwise walk the resolved chain from its head again): dispatch on an untried tier of the same chain, or — when no untried tier remains, or the declared reason says the task's shape is the blocker (too_large/underspecified) — fire #500'srole:impl→role:researchreroute at a threshold of one, because a second identical outcome cannot inform a decision the evidence has already made.
Fail-closed and bounded, in the directions that matter:
- An unreadable health window already leaves the issue deferred with no escalation, and
unknown(the fold target for an unattributable exit) is neitherno_changenor success — so an unreadable exit class can never mark an issue intractable. - The narrowing has its own machine exit,
TIER_EXCLUSION_SECONDS(6 h), 8x tighter than the 48 h health window: a chain narrowed onto a tier with no capacity cannot stall the issue for the full window. - Escalation terminates in
min(len(chain), DECLINE_ESCALATION_MIN)dispatches, each on a distinct tier, and the terminal for an issue already on a non-implementation route is the machine-ownedstatus:parkedsoft hold — neverneeds:user(#703).
The evidence path is itself a YAML seam: worker.yml's exit-class step and the separate
no-target-code model_health job are the only wire carrying a no_change (and its why_no_diff)
into the ledger the dispatcher routes on. Cutting it fails silently — no job goes red, the
dispatcher simply sees no evidence and resumes retrying the same tier — so
dispatch-claim._no_change_seam_violations pins it on parsed nodes (if:, step/job presence,
the needs: edge, both env: inputs, and the --reset-hint argument), with each mutant applied to
the parsed document in memory and required to come back named.
- Tokens: only in GitHub secrets (encrypted at rest, masked in logs), and only in the
dispatch-secretsenvironment — repo scope stays EMPTY, enforced fail-closed every tick byscripts/dispatch-secrets-guard.py(issue #101). The environment's custom deployment-branch policy admits ONLYmaster, so a modified workflow copy dispatched at any other ref is refused the secrets server-side. This covers the 14 pipeline secrets ANDREGISTRY_SECRETS_PAT(currently unset; when restored it goes into the environment — mint it fine-grained with repository Secrets: read + Environments: read/write on this repo: least privilege, because post-cutover the PAT only LISTs/GETs repo-scope secrets (onboarding's both-scopes absence probe) and WRITEs environment secrets — env-secret endpoints sit under the fine-grained "Environments" permission, whose read half covers the env public-key read. Store it withgh secret set REGISTRY_SECRETS_PAT --repo jeswr/agent-account-registry --env dispatch-secrets). pat-validity(weekly cron): probesREGISTRY_SECRETS_PATahead of use —GET /user, thedispatch-secretsenvironment secrets public-key read, then an authoritativegh secret set --env dispatch-secretson the disposableREGISTRY_PAT_PROBE_CANARYsecret (the public-key read alone needs only read access, so it would bless a read-only PAT that onboarding's env write still breaks on; a repo-scope canary would re-trip the secrets-guard weekly) — and upserts one rollingfrom:agentalert issue on invalid/insufficient-scope. Calendar expiry is caught before onboarding stalls on it, and a transient network blip never false-alarms — consecutive network-unknowns are counted in a repository variable (silent state: below the threshold no issue is touched, since GitHub creates every issue open and even a create-then-close would notify) and page via a separate rolling issue once a small threshold is crossed (issue #207), so a permanently-stalled probe cannot leave the PAT silently unverified.- The exfil gate contract is decided, and fails closed when it cannot be.
dispatch-secrets-guard.guard_gate_verdictproves that a failingsecrets-guardprevents every secret-consuming job indispatch.ymlfrom running. Two properties carry the weight, and both are parses, not text searches:- Polarity. A job-level
if:is decided by exhaustive satisfiability over a propositional abstraction, and the guard requirement may be pinned FALSE only when it is a genuinely parsed comparison. A function call's quoted string ARGUMENTS are not conditions — recognising the comparison inside a string literal is how the gate was defeatable in one line — and any atom the grammar cannot fully parse (indexing, arithmetic, a!whose precedence differs from GitHub's, a bare string used as a condition) is reported UNDECIDED, which the gate treats as admitting: an unreadable gate is not a proven gate. - Execution. The guard must actually RUN both verifier invocations. Reachability is decided by
parsing the step's shell body — comments and here-document bodies removed,
&&/||short-circuits andif/while/caseconstructs treated as unreachable — so a commented-out or short-circuited invocation is a refusal that names the fact. The text of a command is not its execution.
- Polarity. A job-level
- Account metadata + selection logic: only in this repo — which is public, so handles, limits and the selection logic are published deliberately (they carry no secrets), token VALUES stay in GitHub secrets, and account emails/PII are redacted from everything written here (locked decision 22).
- Script convention: retry via
scripts/gh_retry.pyfor idempotent reads; NEVER wrap a CAS/ledger write or a mutation-confirmation ingh_retry.run_gh(their conflict/fail-loud semantics are caller-owned — a replayed mutation can double-dispatch a worker, #559/#558). A ledger CAS writer that needs to survive a throttle/availability blip takes the classification and the wait schedule fromscripts/ledger_retry.py(which delegates both togh_retry) and does the retry in its own loop, re-reading the ledger and re-deriving the expected blob SHA every time —select-and-claim.py's lease writer is the reference implementation (#558). Never add a third retry/sleep loop. - Public codebases request a worker and receive an opaque claim; they never see account internals.
- Worker publication is re-attested LIVE, twice (issue #568). The pre-model
truststep runs tens of minutes before push/PR (the job budget is up to 90 minutes, which is why the lane mints two further App tokens across that span), soworker.ymlrepeats the FULL author / body-SHA / status-label / trust-gate revalidation in the fresh publisher immediately beforeCommit, push, and open DRAFT target pull request— a maintainer who closes, rewrites, or human-parks the issue mid-run is never published over. Three properties make it sound: the verifier is a pre-model snapshot — the trust-gate program, theworker-issue.pydriver that runs the live checks and decides whether to invoke it, and thepark_policy.pysibling the driver loads out of its own directory (the whole closure the path executes, or it dies on every run), since the local gate executes target-controlled cargo build scripts on the runner and could otherwise rewrite any of them — taken from the SHA-pinned checkouts intoRUNNER_TEMP(outside every model-container mount) and re-bound to their recorded sha256s, with nothing else permitted in the snapshot directory, so the candidate change can never authorize its own publication —reverify --forbid-gate-rootrefuses at runtime if the gate path resolves into the model-mutable tree; the re-check runs in--mode pre-publish, which accepts this run's ownstatus:in-progressclaim (dispatch mode'sstatus:readydemand would refuse every real run) and nothing else; and ownership is a genuine compare-and-swap — the run-key-bound claim receipt is posted before the sharedstatus:in-progresslabel, so a newer run supersedes an older one even in the pre-attempt window, andholds_live_claimrefuses whenever the newest receipt is not this run's. A step outcome, finally, proves only that a shell exited 0 — which those same build scripts can arrange without the verifier ever running, by persistingBASH_ENV(bash sources it before the block's first line) or a hijackedPATHout of the gate via$GITHUB_ENV/$GITHUB_PATH— so publication is additionally gated on a positive attestation the re-check emits only after the live re-verification returns; the re-check executes on a reset PATH with the interpreter levers cleared and python isolated; and the gate step's own runner command files are redirected to a quarantine path, removing that persistence primitive at its source. Drift ABORTS without mutating human-owned issue state;final_statereturns the issue to the pool.
You don't paste tokens manually. Instead:
- Open a "set up new account" issue (there's a template), label it with one
grant:<owner>/<repo>label per repository the account is authorized for and exactly oneprovider:openai/provider:anthropiclabel, then add theset-up-accountlabel last. Labels are the only carrier — the broker never parses the issue body, so the form collects no request data (#261). Each target must be anenabled = truerow ofpolicy/repos.toml:account_poolis the credential-authorization boundary, so an account is granted ONLY to the repositories the request names (#579), and a request that names none is refused before any login instead of being granted to every repository. - The
set-up-accountworkflow (trust-gated to the maintainer) runs the provider's device/OAuth login and comments a sign-in URL + one-time code on the issue. - Sign in with the account you want to register. The broker captures the resulting token, stores it
as the account secret (
ACCTNN_TOKEN) in this registry'sdispatch-secretsenvironment, and registers the account issuestatus:pending— not yet allocatable. The request issue stays open until the grant PR in step 4 merges (it is closed byactivate, not here). The token is never printed — only written to a mode-600 file and set as a secret. - The grant lands as a checked
account-pool/<handle>PR that edits only the granted rows (scripts/grant-account.py, self-tested), and the labels are re-read live immediately before that write — a target removed during the sign-in window fails closed. The authorized set is recorded on the account issue asgrant_targets:; on merge,activatere-proves that every recorded target lists the handle exactly once, that no other row lists it at all, and — against the merged commit's first parent — that every other row and field is byte-identical, before the account becomesstatus:availableand the request is closed. The PR's own complete merge-base diff is proved separately (position by position, and refused if the API truncated it), because under a multi-commit rebase the first parent is a commit of the same PR. If the handle is somehow already in the policy pool, activation additionally requires every authorized row to be traced to a merged, row-scopedaccount-pool/<handle>PR that provably added the handle to that row: matching shape is not proof that a grant was ever reviewed. That bound is deliberately stated as what it is — each row was at some point established by a checked PR; it does not prove the row's current bytes are the ones that PR wrote, since an unchecked later edit could have removed and re-added the handle. Closing that would need a per-row history walk of the policy document and is not attempted.
Providers: OpenAI via codex login --device-auth (native device flow); Anthropic via
claude setup-token (run in the clean Actions runner). Needs secrets.REGISTRY_SECRETS_PAT (a
fine-grained PAT with repository Secrets: read + Environments: read/write on this registry
repo — least privilege; the broker only lists/reads repo-scope secrets and writes environment
secrets — stored in the dispatch-secrets environment: the broker job is env-bound to resolve
it, and it stores captured tokens into that same environment). The broker fails closed before
any login: its preflight proves the PAT can actually store a credential by invoking the same
authoritative pat-validity probe the weekly cron uses (GET /user + dispatch-secrets
public-key read + repo-secret listing + an authoritative environment canary write) and requiring a
valid write verdict — so a revoked, expired, read-only, or wrong-repository PAT that passes a mere
nonempty check is caught here, not after a credential is already captured. If the PAT is missing,
invalid, or under-scoped, the broker exits without ever surfacing a sign-in URL — remediation
(the exact mint grants and the storage command
gh secret set REGISTRY_SECRETS_PAT --repo jeswr/agent-account-registry --env dispatch-secrets)
is posted on the issue, so a credential is never captured that cannot be stored.
One authorization, three artifacts, read at three different times:
| Artifact | Lives on | Written by | Read by |
|---|---|---|---|
grant:<owner>/<repo> labels |
the request issue | the human opening the request | the broker's authorize step — and re-read live immediately before the pool write |
account_pool = [...] rows |
policy/repos.toml |
the checked account-pool/<handle> PR |
policy-resolve / select-and-claim, at claim time |
grant_targets: <owner>/<repo>[, ...] |
the account issue body (title = the handle) | the broker, when it creates the record (and an operator, by hand, in the recoveries below) | the #211 resume path and the post-merge activate job |
The record is not a duplicate of policy. activate fires on a pull_request event with no access to
the request issue's labels, so grant_targets: is the only readable-from-there statement of the
authorized target set that it can re-prove the merged policy against. grant-account.verify_membership
requires, in the merged document: every recorded target lists the handle exactly once, and no
other row lists it at all. Both legs are refusals — a record that cannot be read, or that disagrees
with policy, fails closed and leaves the account status:pending for a human.
What the record is not. It is an issue body an operator can edit, and nothing re-derives it from
the grant: labels after enrollment, so it is the broker's transcript of an authorization, never the
authorization itself. Editing it changes what the later proofs are compared against; it is not
evidence that the request ever authorized a target, and it cannot supply the row-scoped
account-pool/<handle> provenance the broker's no-op/resume path separately requires. That is why the
first recovery below is a reconstruction of a record whose grant was already reviewed, and the second
one drives the change back through the broker rather than hand-editing the line into agreement.
Symptom, from activate:
refusing to activate acct07: the account record carries no `grant_targets:` line, so the
repositories this account was authorized for cannot be read — refusing to prove or activate
its grant (fail closed)
or, from a re-applied set-up-account on the bound request issue: "…its authorized target
repositories cannot be read … Add a valid grant_targets: line to the issue body, then re-apply
set-up-account."
Who is affected: an account issue created before #579 (no such line) that is still
status:pending with an unmerged account-pool/<handle> PR. The recovery is a one-line hand edit of
the account issue body — not a policy edit — naming exactly the repositories whose account_pool
that pending entry occupies:
grant_targets: jeswr/agent-account-registry, sparq-org/sparq
Shape rules parse_record_line / verify_membership enforce: one line, comma-separated
<owner>/<repo>, anywhere in the body; entries are order- and duplicate-insensitive (sorted and
deduped on read), but each must name an enabled = true row of policy/repos.toml, and the set must
be exactly the rows the grant occupies — an extra target fails the exactly once leg, an omitted
occupied row fails the no other row leg. Then merge the pending PR — activate fires on the
merge and finishes the enrollment. There is nothing to re-run before then: the job is gated on
pull_request.merged == true, so re-triggering it on a still-open PR is skipped and recovers nothing.
The pre-merge resume option is re-applying set-up-account on the bound request issue.
Do not instead hand-add the handle to policy/repos.toml to make the two agree: that trades a
documented one-line record edit for an unreviewed write to the credential-authorization boundary, and
activation would then refuse anyway for want of a merged, row-scoped account-pool/<handle> PR
accounting for the membership.
Do not widen a brokered handle in an ordinary policy PR. That half takes effect for claims as soon
as it merges — policy-resolve / select-and-claim read account_pool and never read
grant_targets: — while establishing no account-pool/<handle> provenance for the new row, and the
next resume for that handle then refuses either way: with the record widened to match,
trace_membership_provenance finds no merged, row-scoped grant PR that established the new row; with
the record left alone, verify_membership sees the handle in a non-target row. Editing the line
afterwards does not repair that — it changes only what the proofs compare against.
Widen through the broker instead, so the audited grant: labels are re-proved and the addition lands
on the checked branch:
- Add the
grant:<owner>/<repo>label for the new repository to the original request issue — the authorization act, on the audited surface the broker actually reads. - Add the same
<owner>/<repo>to that account issue'sgrant_targets:line. Resume treats the recorded set as the request's authorization snapshot and requires the live labels to still equal it (require_same_targets), andactivatereads the record at merge time — so it has to be there before either runs. - Re-apply the
set-up-accountlabel to that request issue — a closed one is fine, the workflow triggers onissues: [labeled]and reconcile keys off the still-open account issue. The #211 resume path skips login and registration (the credential is already captured, the slot is the same handle), re-runsauthorizeover the live labels against policy, and opens the row-scopedaccount-pool/<handle>PR whose only edit is the new row. - Let
gatereview that PR and merge it.activatethen re-proves all four legs against the merged policy — the recorded set, the row-scoped before/after pair, the PR's file scope, and its own merge-base diff — and no-ops on an account that is alreadystatus:available.
If the broker cannot be driven (the request issue is gone, or the account issue no longer resolves as
resumable — it must be open and status:pending/status:available), the fallback is to open the
account-pool/<handle> PR by hand on that branch, record edited first: the human review of that
PR is then the authorization of record, and it is what later traces as provenance for the row. An
ordinary policy PR is never a substitute for it.
Narrowing is the one hand edit that stands on its own: remove the handle from the row in a policy
PR and drop that repository from grant_targets: in the same change window. A row the grant no longer
occupies needs no provenance, and leaving it recorded fails the exactly once leg the next time
resume or activate runs for that handle.
The fail-closed direction throughout is a refused activation rather than a silent grant: the record
decides what activate and resume compare the merged policy against, but what licenses a grant is the
reviewed account-pool/<handle> PR behind it.