Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

crucible

The crucible mark: a vessel of molten metal.

Deterministic agent orchestration

Agents do the work. The engine selects the next task.

Crucible is a deterministic orchestrator for agent workflows. It runs agent turns in sandboxes. You declare the graph, and the engine schedules the tasks, isolates them, and records their cost.

A workflow is a graph of agent turns, commands, and checks. The executor selects the next task, not the agent. The selection rules are fixed, and you can read them before the run starts.

Deterministic

The engine runs the plan that you review

  • Workflows compile to a static plan. Loops and functions unroll at compile time.
  • The executor advances the plan. The dispatch order is stable, and the event stream is reproducible.
  • Outcomes follow fixed rules. Transport failures retry. Measured failures do not retry. If a plan cannot run in full, the engine dispatches nothing.
  • Every run has a cost limit and a time limit.

Sandboxed

The host keeps the credentials

  • Agent turns run in OpenShell sandboxes with deny-by-default egress.
  • Privileged operations go through a broker. The agent sends a request over MCP, and the host does the operation.
  • A pack declares its egress and credentials. crucible check lists them before the run starts.
  • Launch parameters go into prompts. They do not go into a command line.

Two lanes

The same engine runs two kinds of workflow.

A playbook, end to end

A pack is a directory that holds a crucible.toml, a workflow.star, and the scripts that the tasks run. This pack reads a ticket, routes it to one queue, and files it:

read = command(name = "read", run = "./classify.sh", emits = ["bucket"])

gate = route(
    name = "gate",
    depends_on = [read],
    source = read,
    questions = {
        "bucket": choice(
            ask = "Which queue owns this ticket?",
            options = ["outage", "billing", "feature"],
        ),
    },
)

oncall = command(name = "oncall", run = "./file.sh oncall", depends_on = [gate],
                 when = gate.bucket, answers = "outage")
finance = command(name = "finance", run = "./file.sh finance", depends_on = [gate],
                  when = gate.bucket, answers = "billing")
backlog = command(name = "backlog", run = "./file.sh backlog", depends_on = [gate],
                  when = gate.bucket, otherwise = True)

filed = command(name = "filed", run = "./file.sh filed",
                depends_on = [oncall, finance, backlog], join = "passed")

workflow(type = "playbook", tasks = [read, gate, oncall, finance, backlog, filed], result = filed)
$ crucible plan run --manifest crucible.toml --max-cost 1 --max-time 5m
  read                 pass       attempts=1 cost=$0.0000  out={"bucket":"billing"}
  gate                 pass       attempts=1 cost=$0.0000  out={"bucket":{"confidence":1.0,"label":"billing",...}}
  oncall               not_taken  attempts=0 cost=$0.0000  (gate.bucket resolved to billing)
  finance              pass       attempts=1 cost=$0.0000  out={}
  backlog              not_taken  attempts=0 cost=$0.0000  (gate.bucket resolved to billing)
  filed                pass       attempts=1 cost=$0.0000  out={}
plan v1: completed — spent $0.0000 of $1
verdict: valid

The branches that no answer selects settle not_taken. They do not dispatch and do not spend. If you replace source = read with min_confidence = 0.8, a decision model answers the question. The engine records a low-confidence answer as uncertain, and the otherwise task takes it.

A playbook run in the crucible control plane: the admitted task graph with a fanned-out build and measure, one failed instance, and the judge that kept the winner.
A playbook run in the control plane: a fanned-out build and measure, one failed instance, an advisory profile, and each task's cost.

Where to go next

The pieces

CrateWhat it is
crucible/The engine: the Starlark compiler, the plan executor, the autoresearch loop, agent backends, and crucible deploy.
crucible-controller/The control plane: a Postgres ledger, the reconciling daemon, the HTTP API, and the embedded UI.
crux/The CLI and MCP tools over the controller API.
crucible-broker/The broker. The host keeps the credentials, and the sandboxed agent sends requests over MCP.
forge/Image builds, registry, and deployment support.

Your first playbook

A playbook is a graph that runs once. This page builds a playbook from an empty directory. An agent writes a haiku, and a command counts its lines. If the count is wrong, the command sends the draft back for a second round. The first run uses a stand-in for the agent and costs nothing. The second run uses a real agent.

You need a crucible binary on PATH (installation). Every build includes the playbook lane. Only the scored loop needs --features autoresearch.

The pack

A pack is a directory that holds all the files a run needs:

haiku/
├── crucible.toml    the manifest: the agent, the workspace, which graph to run
├── workflow.star    the graph
├── count.sh         the reviewer
└── stand-in.sh      a fake agent, for runs without a model
  1. The manifest. crucible.toml sets the backend for agent turns and the initial content of the workspace. Without [repo], the workspace starts empty and holds only the files that inject lists.

    [workspace]
    inject = ["count.sh", "stand-in.sh"]
    
    [agent]
    backend = "command"
    agent_cmd = "./stand-in.sh"
    goal = "Write a haiku about molten metal."
    
    [workflow]
    type = "playbook"
    file = "workflow.star"
    

    backend = "command" runs agent_cmd instead of a model. The rest of the pack sees no difference, so you can test the pack in CI.

  2. The graph. workflow.star declares the tasks and the edges between them.

    poem = agent(
        name = "poem",
        prompt = "Write a haiku about molten metal to HAIKU.md. Report its line count as `lines`.",
        emits = ["lines"],
        emits_files = ["HAIKU.md"],
    )
    
    check = command(
        name = "check",
        run = "./count.sh",
        depends_on = [poem],
        revise = poem,
        max_rounds = 2,
    )
    
    workflow(type = "playbook", tasks = [poem, check])
    

    emits declares the output fields. A passing poem must return a lines field. If the field is missing, poem fails, so the error appears at its source and not in a later task. emits_files names the file that the engine stages for check. revise = poem makes check a reviewer. If check fails, poem runs again with the verdict. There are at most two rounds.

  3. The scripts. A command task passes if it exits with status zero. Its last stdout line is its JSON output. This is the reviewer:

    #!/bin/sh
    n=$(grep -c . HAIKU.md)
    printf '{"lines": %s}\n' "$n"
    [ "$n" -eq 3 ]
    

    The stand-in writes four lines in the first round. When its prompt contains the reviewer's verdict under revision, it writes three lines:

    #!/bin/sh
    case "$CRUCIBLE_PROMPT" in
    *'"revision"'*) printf 'molten in the dark\nthe crucible holds its breath\npour, and it is steel\n' > HAIKU.md ;;
    *) printf 'molten in the dark\nthe crucible holds its breath\nand then some more\npour, and it is steel\n' > HAIKU.md ;;
    esac
    echo "{\"lines\": $(grep -c . HAIKU.md)}" > PLAN_TASK_RESULT.json
    

    An agent turn reports its result in PLAN_TASK_RESULT.json in the workspace root. The engine adds this instruction to every agent prompt. Name the emits fields in the prompt, as poem does with lines.

Run it

chmod +x count.sh stand-in.sh
crucible check --manifest crucible.toml
crucible plan run --manifest crucible.toml --max-cost 1 --max-time 5m
  poem                 pass       attempts=1 cost=$0.0000  out={"lines":3}
  check                pass       attempts=1 cost=$0.0000  out={"lines":3}
plan v1: completed — spent $0.0000 of $1
verdict: valid

--max-cost and --max-time are required. A pack cannot set its own limits. The person or system that launches the pack sets them. Once spend reaches --max-cost, no further agent task, model-decided route, or fan-out lane starts. At most eight lanes of one fan-out run at once, so the overrun is at most what the lanes already running go on to spend. Commands still run after the cost ceiling, so the run reports the work it paid for. --max-time stops everything.

crucible check validates the manifest, resolves every file that it references, and lists the egress and credentials that the pack would get. It does not run a task.

The verdict is valid because every required task passed. Use the verdict in scripts. plan run exits with status 3 for any other verdict, and with status 1 if it cannot run the plan.

What the run left behind

The run writes two directories next to the manifest. workspace/ is a Git repository, and each passing task makes a commit:

$ git -C workspace log --format=%s
task poem
task poem
autoresearch: baseline workspace

There are two task poem commits. The first draft passed as a task because it wrote its result. The review failed it, and the second round replaced it. plan run prints the final state of each task. state/session.jsonl records every round:

$ jq -r 'select(.task != null and .status != null) | "\(.task) \(.status)"' state/session.jsonl
poem[round-1] pass
check[round-1] fail
poem[round-2] pass
check[round-2] pass
poem pass
check pass

Report a pull request or a pushed branch

If a task makes something outside the run, declare the field that holds its URL as link. Declare a list of URLs as links:

deliver = skill(
    name = "deliver",
    skill = "open-pr",
    depends_on = [check],
    emits = {"pushed": "links", "pr": "link"},
)
{"pushed": ["https://github.com/neuralmagic/crucible/tree/haiku"], "pr": "https://github.com/neuralmagic/crucible/pull/123"}

Only http and https URLs are valid. Any other value fails the task. The engine reads the host and path of each URL to name it (github, a pull request, #123). The run and task views show a provider mark that links to the URL.

If the run is interrupted

plan run --resume continues the run in state/:

crucible plan run --manifest crucible.toml --max-cost 1 --max-time 5m --resume

A task that the session log already settled does not run again. This includes single items of a fan-out and single rounds of a revise loop. The workspace goes back to the commit where the run started. A later task sees the files that earlier tasks declared in emits_files, not other files they left in the tree. Spend and elapsed time from before the interruption count against the limits. Elapsed time includes the time when the run was down. A resume fails if the pack, a parameter, or --max-cost is different from the start of the run. If the run already shut down, a resume only exits with its verdict again.

A pod that the control plane dispatches resumes automatically. If the engine stops, the container restarts and resumes, up to three times. A pod that you render by hand does the same if its profile sets [cluster] state_pvc.

Rerun from a task

If a late task fails, fix it and rerun from that task. You do not have to run the whole graph again. Move the state of the finished run to a different directory, and give that directory to --rerun-of:

mv state parent
crucible plan run --manifest crucible.toml --max-cost 1 --max-time 5m \
  --rerun-of parent --from check

check and all tasks downstream of it run again. Each task that did not pass in the parent run also runs again. The rerun reuses all other tasks. Their output and declared files go to their dependents as before, and the rerun replays their commits onto the commit where the parent started. The session log marks each reused row with reused_from, which names the run that settled it. The spend of reused rows does not count against the new limits.

You can edit the tasks that you rerun. If you edit a task that the rerun would reuse, the rerun fails and names the task. This prevents a stale result from being reused. Add the task to --from to run it again. This check compares only what the graph declares. It does not compare the scripts in the pack. If you edit only check.sh, no task runs again because of that edit.

In the control plane, the page of a finished launch has a Rerun button. It shows which tasks the rerun reuses and the expected cost. From the CLI, launch the draft or playbook again and name the run to rerun:

crux draft-launch haiku --max-cost 1 --max-time 5m --rerun-of <run id> --from check

A draft rerun runs the newest save. A pod rerun gets the parent's evidence from the controller at [cluster].ingest_url. The profile of a pod executor must set this value.

Put a real agent on it

Remove agent_cmd and select a backend:

[agent]
backend = "local"
harness = "claude"
goal = "Write a haiku about molten metal."
BackendWhere the turn runs
commandagent_cmd, no model. For tests.
localThe harness CLI on this machine, under your login.
openshellAn OpenShell sandbox from sandbox_image, with deny-by-default egress.

The harness is claude, codex, opencode, or pi. The --harness and --model options of plan run override the manifest for one run. A task can set its own values with agent(..., harness = "codex", model = "..."). A real run costs money. --max-cost limits that cost.

With openshell, a task can run in its own sandbox. Declare the sandbox in the manifest and name it on the task:

[agent.sandbox.go]
image = "ghcr.io/acme/sandbox-go@sha256:..."
secrets = []                                  # [[secret]] names with an env projection
relays = []                                   # [[agent.relay]] destinations
broker = false                                # reach the [agent.broker]
mcp = []                                      # [mcp] servers this sandbox reaches
endpoints = ["proxy.golang.org:443:read-only"]
analyze = agent(name = "analyze", prompt = "...", sandbox = "go")

The turn starts from that image. It adds those endpoints to the endpoints of [agent.openshell]. From the secrets, relay files, and [mcp] servers that the pack declares, the turn gets only the ones that the sandbox lists. The turn can reach the broker only if broker = true. Every turn still gets the model credentials of the deployment. A task without sandbox runs in sandbox_image and gets every declared secret and relay. Tasks that share a session must use the same sandbox.

A sandbox limits what the engine provisions for a task. It does not limit what a task reads from upstream tasks. If a task had a secret, its output, files, and workspace changes still go to the tasks after it. The capability disclosure lists each sandbox. At launch, the controller checks each sandbox image against the catalog.

MCP servers

With openshell, a pack can start MCP servers on the loop pod. Use them for tools that need credentials that the sandbox must not have:

[mcp.buildit]
bin = "/usr/local/bin/buildit"
args = ["mcp"]
env = { BUILDIT_NAMESPACE = "builds" }        # set on the server
inherit = ["KUBERNETES_SERVICE_HOST", "KUBERNETES_SERVICE_PORT"]  # copied from the loop pod
tools = ["build", "run", "logs"]              # passed as MCP_TOOLS

[agent]
mcp = ["buildit"]                             # turns without a named sandbox

A turn can reach only the servers that its scope names. The scope is [agent].mcp, or the mcp list of the named sandbox that the turn runs in. The default is no servers. The harness configuration of the turn lists only those servers.

Each server runs as a separate process on a separate port. Ports start at 8850 and follow key order. A server gets only these environment variables: PATH, HOME, its env and inherit names, MCP_NAME, MCP_BIND, MCP_TOKENS_FILE, and MCP_TOOLS. Every turn gets a new token for each server. The engine writes the token to MCP_TOKENS_FILE with the sandbox and workdir of the turn (format). The engine revokes the token when it deletes the sandbox. The server identifies the caller from the token alone.

Parameters

A pack that takes input declares a params block as the first statement of workflow.star:

params = {
    "topic": {"type": "string", "default": "molten metal", "doc": "what the haiku is about"},
}

poem = agent(
    name = "poem",
    prompt = "Write a haiku about " + param("topic") + " to HAIKU.md. Report its line count as `lines`.",
    emits = ["lines"],
    emits_files = ["HAIKU.md"],
)
crucible plan params --file workflow.star           # the JSON Schema, without running anything
crucible plan run --manifest crucible.toml --param topic=slag --max-cost 1 --max-time 5m

A string parameter that sets "multiline": True takes its value in a text area on the control plane's launch, schedule and ask forms. The value is still one string.

Parameters go into prompts and skill arguments. They do not go into a command line. If you put a parameter into run, the compiler rejects the pack:

argument "run" carries a value supplied from outside the pack. A prompt marks such a span so
an agent can tell it from an instruction; nothing else can, so do not build it into "run". A
command or evaluate task reads it as data from the "params" entry of the inputs JSON in $CRUCIBLE_INPUTS_FILE.

A command or evaluate task reads each declared parameter from the params entry of the inputs JSON. CRUCIBLE_INPUTS_FILE names that file. Each parameter has its declared name and type, and the file also holds the outputs of the task's dependencies. If the source has no params block, the entry is an empty object. Agent tasks do not get this entry. They get parameter values only through the prompt.

fetch = command(
    name = "fetch",
    run = "python3 -c 'import json, os; p = json.load(open(os.environ[\"CRUCIBLE_INPUTS_FILE\"]))[\"params\"]; print(json.dumps({\"topic\": p[\"topic\"]}))'",
)

params is a reserved name. A task cannot name a dependency params.

The control plane validates a launch against the same schema.

History

A playbook that runs on a schedule can read the results of its last runs. The pack names one task as its record task. An agent, skill, command, or evaluate task can request up to 30 earlier runs:

triage = agent(
    name = "triage",
    prompt = "Find what is broken. Check the run history first: skip what earlier runs could not fix.",
    history = 5,
    emits = ["broken", "tried"],
)
workflow(type = "playbook", tasks = [triage], history_record = triage)

Each run records only the status of the record task and the fields that it declares in emits. A later run of the same standing launch gets those records under history, oldest first. The list includes failed runs and runs that timed out:

{"records": [{"run": "...", "started_at": "...", "ended_at": "...", "outcome": "finished",
              "verdict": "valid", "revision": "...", "link": "...",
              "entry": {"task": "triage", "status": "pass", "output": {"broken": 2, "tried": ["..."]}}}],
 "dropped": 0}

A command reads the history from the inputs file that CRUCIBLE_INPUTS_FILE names. An agent sees it in its prompt, marked as external input, because an earlier agent wrote part of it. If the records are larger than the size limit that the operator sets, the engine removes the oldest full records and counts them in dropped. crucible check prints the limit.

A manual launch is not part of a series, so it gets an empty list. A local plan run also gets an empty list, unless CRUCIBLE_HISTORY is set. To record a result when the main graph fails, make the record task an epilogue task. The record shape of a pack can change between revisions, so a reader must accept older entries. Each record contains the revision that wrote it.

Launch it from the control plane

The control plane has a registry of playbooks, a draft studio to edit them, and a ledger of all launches. Start a controller and point crux at it. Then:

crux draft-create haiku --description "a haiku, reviewed"
crux draft-push haiku ./haiku --base-version 1
crux draft-launch haiku --max-cost 1 --max-time 5m

draft-push saves the directory as the next version of the draft and compiles it. When the draft is ready, crux draft-graduate opens a PR that exports it to a repository. crux playbook-import owner/repo --path haiku proposes to register it from that repository. After registration, crux launch starts the playbook with parameters, and crux playbook-runs lists its runs.

A registration runs one variant of the pack's manifest (variants). Add --variant offline to playbook-import, or pick the variant in the import wizard, which lists the base manifest and every variant of each pack. Every launch of the registration then runs that variant. To run another variant of the same pack, register it again under another id.

Let your own agent write it

If an agent writes the pack, give it the co-draft skill before it starts. The controller generates the skill at <controller>/api/playbooks/drafts/skill, and the skill contains the controller URL. The skill tells the agent how to use the draft tools, how to use base_version when it saves, and when to stop. The Agent skill button on the drafts page downloads the skill, and crux draft-create prints the link. Install the skill and register the MCP endpoint of the controller:

mkdir -p ~/.claude/skills/crucible-co-draft
curl -fsSL -o ~/.claude/skills/crucible-co-draft/SKILL.md \
  -H "Authorization: Bearer $CONTROLLER_API_TOKEN" \
  "$CONTROLLER_URL/api/playbooks/drafts/skill"
claude mcp add --transport http crucible "$CONTROLLER_URL/mcp" \
  --header "Authorization: Bearer $CONTROLLER_API_TOKEN"

Then ask the agent for a pack. The agent saves into a draft, and you can see it compile in the studio. When the diagnostics are clean, the agent gives you the studio link.

Next: Branching and review describes routes, fan-out, sessions, and advisory tasks. examples/playbook, examples/route, examples/revise-loop, and examples/triage are complete packs that you can copy.

Launching from webhooks

A webhook launches a registered playbook when a sender outside the controller posts a delivery. A sender is any system that POSTs JSON to a URL: quay.io after an image push, GitHub after a release, a CI job, or your own script. The controller verifies the delivery and puts it in a queue. Within a few seconds, the controller takes the delivery from the queue and runs a small CEL transform. The transform decides if the delivery launches a run, which event it is, and which params the launch gets.

flowchart LR
  S[sender] -->|POST /hooks/id| L[delivery listener]
  L -->|genuine: queue it, 202| D[(delivery queue)]
  L -->|anything else: 404| S
  D --> W[controller, every few seconds]
  W -->|filter, dedupe, rate, params| R{outcome}
  R -->|launched| P[playbook run]
  R --> F[filtered / duplicate / throttled / failed]

The listener does not launch runs. It answers 202 when the delivery is in the queue. The controller then processes the queue of each webhook in arrival order. Each delivery gets exactly one outcome, and you can read that outcome later.

Verifiers

A webhook verifies each delivery with one of two verifiers. You select the verifier when you create the webhook, and you cannot change it later. To use a different verifier, delete the webhook and create a new one.

VerifierSenderWhat a delivery carries
path_tokenAny sender: quay.io, GitLab, a CI job, curlThe secret as the last path segment: /hooks/<id>/<token>
hmac_sha256GitHub, or any sender that signs the same waysha256= and the lowercase hex HMAC-SHA256 of the raw body, in the header you name (x-hub-signature-256)

A sender that signs in a different way (a base64 signature, or a signature over a timestamp and the body) cannot use hmac_sha256. Give that sender a path_token URL. The token is the credential.

The controller generates every secret (256 random bits). It shows the secret once, in the response to the create or rotate request. The controller stores path tokens as SHA-256 digests. It seals HMAC secrets with AES-256-GCM under the webhook key. A controller without a webhook key rejects hmac_sha256 webhooks.

If the controller does not record a delivery, the answer is always 404 with {"error":"not found"}. This applies to an unknown or deleted webhook, a paused webhook, a wrong token or signature, and a body larger than 512 KiB. Webhook ids are random UUIDs, so the address alone gives no information.

Creating a webhook

From the UI

Open a playbook and select Add webhook. The default Sender is Custom, for any sender that POSTs JSON. With Custom, the filter launches every delivery, the dedupe key is delivery, and every param is a fixed value until you derive it. The named senders (quay.io repository push, GitHub release, GitHub push) set the verifier, the transform, and the params that they can match by name. If you select Custom again, the transform starts from the beginning. For each param that the playbook declares, select fixed value or derived (CEL). The form checks each expression as you type and marks the location of each error. Run preview evaluates the transform on a sample body and headers. It does not store anything.

When you save, the form shows the delivery URL and the secret one time. Configure the sender with them:

  • Any sender: POST JSON to the delivery URL (it includes the token). See A custom sender.
  • quay.io: repository settings, Create Notification, event Push to Repository, method Webhook POST, URL = the delivery URL.
  • GitHub: repository settings, Webhooks, Add webhook, payload URL = the delivery URL, content type application/json, secret = the HMAC secret, then choose the events.

Webhooks in the navigation lists every webhook that you can read. The page of a webhook shows its delivery URL, its transform, the pause/resume, rotate, and delete controls, and its delivery log. Edit opens the form again. Its preview can load any recorded delivery as the sample.

A custom sender

Any system that can send an HTTP POST with a JSON body can use a path_token webhook:

curl -X POST -H 'content-type: application/json' \
  -d '{"service": "api", "version": "1.4.2", "env": "staging"}' \
  "$DELIVERY_URL"

You choose the body format. Write the transform for that format:

  • a filter, for example body.env == "staging";
  • a dedupe key, for example body.service + "@" + body.version, so that a retried POST launches only once;
  • a derive for each param, for example version: body.version.

To test the transform on a real delivery, load a recorded delivery into the preview of the edit form. If a sender sometimes omits a key, guard it: has(body.env) && body.env == "staging".

An hmac_sha256 sender also signs the raw body. It puts the signature in the header that the webhook names (x-signature in this example):

body='{"service": "api", "version": "1.4.2"}'
sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -X POST -H 'content-type: application/json' -H "x-signature: sha256=$sig" \
  -d "$body" "$DELIVERY_URL"

The body must be JSON. The controller records any other body with the outcome failed.

From crux

crux webhook-presets                    # starting points, as JSON
crux webhook-check --file hook.json     # CEL diagnostics, field:line:col; fails on any
crux webhook-create --file hook.json    # prints the webhook, its secret, and delivery URL, once
crux webhooks                           # list
crux webhook <id>                       # one, as JSON
crux webhook-deliveries <id> [--before <delivery-id>] [--limit N]
crux webhook-update <id> --file hook.json
crux webhook-pause <id>                 # its address answers 404; queued deliveries fail
crux webhook-resume <id>
crux webhook-rotate <id>                # new secret, shown once; the old one stops working
crux webhook-delete <id>
crux webhook-preview --file preview.json

A create body:

{
  "playbook": "on-image-push",
  "verifier": "path_token",
  "filter": "\"latest\" in body.updated_tags",
  "dedupe": "delivery",
  "derive": { "image": "body.docker_url", "tags": "body.updated_tags" },
  "params": { "release": "3.2" },
  "max_launches_per_hour": 10,
  "retention_days": 14,
  "max_cost": 5,
  "max_time": "30m"
}

params are fixed values. The controller validates them against the schema of the playbook, as it does for a launch form. derive maps a declared param to the expression that derives it. A param is fixed or derived, not both. An hmac_sha256 body also contains "header": "x-hub-signature-256".

From the API

POST /api/webhooks, GET /api/webhooks, GET|PUT|DELETE /api/webhooks/{id}, POST /api/webhooks/{id}/enabled, POST /api/webhooks/{id}/secret, GET /api/webhooks/{id}/deliveries?before=&limit=, POST /api/webhooks/preview, POST /api/webhooks/check, GET /api/webhooks/cel, GET /api/webhooks/presets. The schemas are in /api/openapi.json. For authorization, a webhook is a standing launch. To read a webhook, you need read on it. To change it, you need update. If a save changes what the webhook launches, the policy also evaluates the save as a launch of the playbook.

The transform

The transform has three kinds of CEL expression. All of them use the same variables:

VariableValue
bodyThe delivery's JSON body. Integers are int, other numbers double.
headersThe recorded headers, names lowercased: headers["x-github-event"].
deliveryThe delivery's id, unique per delivery.
received_atWhen it arrived, RFC 3339.
  • filter is a bool. A delivery that it rejects ends filtered. If there is no filter, the value is true.
  • dedupe is a string or int, at most 512 bytes. If a key already launched a run, a new delivery with that key ends duplicate. This is true while the webhook exists, also after the controller prunes the delivery that used the key. With delivery, each delivery is a separate event.
  • derive.<param> returns a string, number, bool, or list of strings for that param.

Functions: size, int, uint, double, string, bytes, type, timestamp, duration, and on a target contains, startsWith, endsWith, matches. Macros: has, all, exists, exists_one, map, filter. GET /api/webhooks/cel returns the same lists. There is no join.

The controller rejects a save if an expression has one of these problems. The error names the field, and the line and column when the parser knows them:

  • it does not parse, it nests brackets more than 32 levels deep, or it is longer than 2 KiB;
  • it reads a variable other than the four above, or it calls a function that is not in the list above;
  • it nests comprehensions more than two levels deep;
  • it has a comprehension whose condition or step reads values other than its own variables. For example, body.l.map(x, body.l) is rejected, and body.items.exists(i, i.tags.exists(t, t == "x")) is allowed. This rule keeps the evaluation time linear in the size of the body.

Examples

quay.io push of a release tag:

filter  body.updated_tags.exists(t, t.matches("^v[0-9]+"))
dedupe  delivery
image   body.docker_url + ":" + body.updated_tags.filter(t, t.matches("^v"))[0]

GitHub release:

filter  headers["x-github-event"] == "release" && body.action == "published"
dedupe  string(body.release.id)
tag     body.release.tag_name

The quay.io push payload names the pushed tags but no digest. For this reason, the quay preset uses delivery as the dedupe key, and every push launches a run. If a playbook pushes the image that it listens to, the rate limit stops it from launching itself in a loop.

Outcomes

A delivery shows queued until the controller processes it, which takes a few seconds at most. The controller processes deliveries in arrival order. Each delivery stops at the first step that gives it an outcome:

StepOutcome
The body is not JSONfailed
The filter is false, or failsfiltered, or failed
The dedupe key cannot be derivedfailed
The key already launchedduplicate
The hour's launches reached max_launches_per_hourthrottled (never retried)
A param cannot be derived, or the schema refuses it, or the launch is deniedfailed
Otherwiselaunched, with the launch key

Failures that a delivery causes do not count toward disabling the webhook. Failures of the launch do count: the playbook is gone, its stored params do not fit its schema, or the policy denies it. A webhook that fails CONTROLLER_SCHEDULE_AUTO_DISABLE_FAILURES times in sequence pauses itself. Schedules and watches use the same threshold. When you pause or delete a webhook, its queued deliveries fail. The controller keeps finished deliveries for retention_days (1 to 90, default 14). max_launches_per_hour is 1 to 120.

Any user who can read the webhook can read the body and headers of its deliveries. The controller does not record a header that can contain a credential (authorization, cookie, the signature header).

Deploying

VariableMeaning
CONTROLLER_HOOKS_ADDRhost:port the delivery listener binds. Unset serves no listener.
CONTROLLER_HOOKS_PUBLIC_URLThe listener's public base URL; delivery URLs are built on it. Unset returns only the path.
CONTROLLER_WEBHOOK_KEY_FILE or CONTROLLER_WEBHOOK_KEYBase64 AES-256 keys, newest first, that seal hmac_sha256 secrets. Unset refuses hmac_sha256 webhooks.

The listener uses a different port from the API, so a deployment can publish only the listener. In the Helm chart, webhooks.enabled adds the hooks container, the Service port, and a Route on the external host for the /hooks path only. webhooks.key.existingSecret mounts the webhook key. The webhook key is separate from CONTROLLER_CREDENTIAL_KEY, which also enables offline credentials at sign-in.

Generate a key with head -c 32 /dev/urandom | base64. To rotate the key, put the new key first. Keep the old key after it until you rotate every secret that it sealed.

Trying it locally

just controller-local serves the delivery listener on 127.0.0.1:8871 (set HOOKS_PORT to change it). It generates a webhook key one time in its state directory. A pack with only commands can test webhooks without an agent:

mkdir -p /tmp/image-pushed && cd /tmp/image-pushed
cat > crucible.toml <<'EOF'
[workspace]
inject = ["announce.sh"]

[agent]
backend = "command"
agent_cmd = "true"
goal = "Record which image a registry push named."

[workflow]
type = "playbook"
file = "workflow.star"
EOF
cat > workflow.star <<'EOF'
params = {
    "image": {"type": "string", "required": True, "pattern": "^quay\\.io/"},
    "tags": {"type": "list<string>", "default": ["latest"]},
}

announce = command(name = "announce", run = "./announce.sh")

workflow(type = "playbook", tasks = [announce], result = announce)
EOF
printf '#!/bin/sh\necho "$CRUCIBLE_INPUTS"\n' > announce.sh && chmod +x announce.sh

crux draft-create image-pushed --description "Record which image a registry push named"
crux draft-push image-pushed /tmp/image-pushed --base-version 1
crux draft-publish image-pushed --playbook on-image-push

Create a quay webhook for on-image-push, with the body above or in the UI. Then post a push as quay.io does, to the delivery_url that the create command printed:

curl -X POST --data '{"repository":"org/img","docker_url":"quay.io/org/img","updated_tags":["latest"]}' \
  "$DELIVERY_URL"
crux webhook-deliveries <id>

The controller processes the delivery within a few seconds. POST /api/reconcile processes it immediately. A GitHub delivery to an hmac_sha256 webhook signs its body:

sig="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')"
curl -X POST -H "X-GitHub-Event: release" -H "X-Hub-Signature-256: $sig" --data "$BODY" "$DELIVERY_URL"

Troubleshooting

SymptomCause
The sender sees 404Wrong id or token, a changed or rotated secret, a paused webhook, a signature over a different body, or a body over 512 KiB.
The sender sees 503The controller could not record the delivery, or it is rejecting load. The sender retries.
filteredThe filter was false. Load the delivery into the preview of the edit form to find the cause.
Stays queuedThe owner of the webhook must sign in again, or the controller is not running its launch loop.
failed with no key "x"An expression read a key that the payload does not have. Guard a field with has(body.x) and a header with "x" in headers before the expression reads it.
duplicateThe dedupe key already launched a run. Use delivery if each delivery is a separate event.
The save rejects hmac_sha256The controller has no webhook key.

ADR-0055 records the design. The webhook clauses of RFC-0003 give the requirements.

Branching and review

The task set is fixed at compile time. Three constructs make decisions at run time, and each has a limit:

  • route() selects branches.
  • revise sends work back to an earlier task.
  • over fans out one instance for each item of a list.

This page describes these three constructs and the related tools: sessions, gates, joins, advisory tasks, and reports.

The DSL reference lists every argument. Work graphs describes what the executor does with each result.

Branch on a decision

A route() asks typed questions about the output of its dependencies. It records one answer for each question. A downstream task uses when to name the answer that it runs on.

flowchart LR
    read --> gate{gate.bucket}
    gate -- outage --> oncall
    gate -- billing --> finance
    gate -- otherwise --> backlog
    oncall --> filed
    finance --> filed
    backlog --> filed
read = command(name = "read", run = "./read.sh", emits = ["bucket"])

gate = route(
    name = "gate",
    depends_on = [read],
    source = read,
    questions = {
        "bucket": choice(
            ask = "Which queue owns this ticket?",
            options = ["outage", "billing", "feature"],
        ),
    },
)

oncall = command(name = "oncall", run = "./act.sh oncall", depends_on = [gate],
                 when = gate.bucket, answers = "outage")
finance = command(name = "finance", run = "./act.sh finance", depends_on = [gate],
                  when = gate.bucket, answers = "billing")
backlog = command(name = "backlog", run = "./act.sh backlog", depends_on = [gate],
                  when = gate.bucket, otherwise = True)

filed = command(name = "filed", run = "./act.sh filed",
                depends_on = [oncall, finance, backlog], join = "passed")

If the route does not select the answer of a branch, the branch settles not_taken. It does not dispatch, does not spend, and does not change the verdict. A task that depends on it with the default join = "all" is also not taken. For this reason, filed uses join = "passed".

To run a task whether or not one branch was taken, keep join = "all" and mark that dependency optional(...):

summary = command(name = "summary", run = "./summarize.sh",
                  depends_on = [read, optional(oncall)])

summary runs when oncall passed or was not taken, and its inputs have no oncall entry in the second case. If oncall failed, summary is blocked as before. Every other dependency keeps the all rule.

Source of the answer. Set exactly one of these two:

Answered byCost
source = readA dependency's own output: read emits {"bucket": "billing"}.Free and deterministic.
min_confidence = 0.8A decision model, through the broker's decision capability. An answer below the threshold is recorded as "uncertain".One model call per route, for all its questions.

If a source emits a label that the question does not declare, the route fails and the run stops at that route. If a model-backed route has no decision binding, the engine truncates the plan before any task spends.

Question types.

  • choice(ask, options) selects one of N labels. options can be a dict, and its values describe each label for the model.
  • noul(ask) answers "yes" or "no". when = gate.urgent without answers runs on "yes".
  • score(ask, levels) is an ordinal question. levels lists two or more labels, lowest first, optionally as a dict of descriptions. It branches as a choice on its most probable level (the lower level if two are equal). It also records a score in [0, 1], which is the expected level position. For low, medium, high, the positions are 0, 0.5, and 1:
"risk": score(ask = "How risky is this change?",
              levels = {"low": "cosmetic", "medium": "one component", "high": "crosses services"}),

A source answers a score with one level label, not a boolean, and the route records the position of that level. "uncertain" records no score. System One has no ordinal question type, so the engine asks a model-backed score as a choice over the levels and records it with "asked_as": "choice".

Each answer must have a task. The compiler rejects a route if an answer that a when can see does not reach a task. This includes "uncertain". Use otherwise = True to cover the remaining answers, or use drop to declare that an answer goes to no task:

"urgent": noul(ask = "Does the customer need a reply within the hour?", drop = ["no", "uncertain"]),

If you add an option to a question, otherwise takes the new answer, or the pack does not compile until a task takes the new label. examples/route is the full pack. It has a model-backed gate and instructions to serve the decision model.

Let a decision model answer

A decision model reads the inputs of the route and returns a probability for each label of each question. It does not return free text that an agent must format correctly. Crucible supports two decision APIs:

  • the OpenAI Decisions API (POST /v1/decisions, protocol decisions);
  • any server that implements the System One decision API (POST /v1/systemone, protocol system_one), for example the hosted Jev API or the vLLM DiffusionGemma structured-reads server.

The route declares the questions and the confidence that it needs. The route is the same for both APIs:

read = command(name = "read", run = "./read.sh", emits = ["ticket"])

gate = route(
    name = "gate",
    depends_on = [read],
    min_confidence = 0.8,
    questions = {
        "bucket": choice(
            ask = "Which queue owns this ticket?",
            options = {
                "outage": "the product is down or unusable",
                "billing": "charges, invoices, refunds",
                "feature": "a request for something new",
            },
        ),
        "urgent": noul(
            ask = "Does the customer need a reply within the hour?",
            drop = ["no", "uncertain"],
        ),
    },
)

The workflow does not name the endpoint. The person or system that launches the run binds an endpoint in CRUCIBLE_INFERENCE:

export CRUCIBLE_INFERENCE='{"version":1,"bindings":[{"role":"decision","protocol":"decisions",
  "model":"gpt-6-luna","key_env":"OPENAI_API_KEY"}]}'
crucible plan run --manifest crucible.toml --max-cost 1 --max-time 5m

A decisions binding without a url uses https://api.openai.com/v1/decisions. To use a gateway, set url. A system_one binding must always name its server:

export CRUCIBLE_INFERENCE='{"version":1,"bindings":[{"role":"decision","protocol":"system_one",
  "url":"https://api.example.com/v1/systemone","model":"jev-1","key_env":"JEV_API_KEY"}]}'

key_env names the variable that holds the bearer key. The document does not contain the key. Without a decision binding, the route cannot run, so the engine truncates the plan before any task spends.

Model input. The engine sends one request for each route. The request holds the outputs of the route's dependencies and each question with its instructions and label descriptions. System One takes the outputs as a state object:

{
  "model": "jev-1",
  "state": {"read": {"ticket": "I was charged twice for the March invoice. No rush, but please refund the duplicate."}},
  "questions": {
    "bucket": {"type": "choice", "instructions": "Which queue owns this ticket?",
               "criteria": {"outage": "the product is down or unusable",
                            "billing": "charges, invoices, refunds",
                            "feature": "a request for something new"}},
    "urgent": {"type": "noul", "instructions": "Does the customer need a reply within the hour?"}
  }
}

The OpenAI API takes the same outputs as JSON text in input. A noul becomes a predicate question, and a choice becomes a choice question with the question id as its name:

{
  "model": "gpt-6-luna",
  "input": "{\"read\":{\"ticket\":\"I was charged twice for the March invoice. No rush, but please refund the duplicate.\"}}",
  "questions": [
    {"type": "choice", "name": "bucket", "instructions": "Which queue owns this ticket?",
     "choices": [{"value": "outage", "description": "the product is down or unusable"},
                 {"value": "billing", "description": "charges, invoices, refunds"},
                 {"value": "feature", "description": "a request for something new"}]},
    {"type": "predicate", "name": "urgent", "instructions": "Does the customer need a reply within the hour?"}
  ]
}

The label descriptions are the only definition of each queue that the model gets. Write them as you would write instructions for a person.

Run record. The output of the route is the decision. For each question, it holds the label, its confidence, and the full distribution that the model returned. The label is the most probable declared label. The engine ignores any separate choice or confidence value that the API returns.

{"bucket": {"label": "billing", "confidence": 0.9,
            "probabilities": {"billing": 0.9, "feature": 0.05, "outage": 0.05}},
 "urgent": {"label": "no", "confidence": 0.9, "probabilities": {"no": 0.9, "yes": 0.1}}}

If the probability of a label is less than min_confidence, the engine records "uncertain", and otherwise or drop must cover it. The engine also records "uncertain", with confidence 0 and an empty distribution, if OpenAI does not answer a question.

Control plane. A deployed controller binds the decision model from its provider registry. On the Providers page, add a default with role decision for the playbook class, for the platform or for one domain. The default names an OpenAI provider (which uses gpt-6-luna if the default names no model) or a custom provider with protocol decisions or system_one. Every playbook launch then gets this binding and the provider key, together with the binding of its agent (ADR-0065). A controller that you start with just controller-local does not read secret values. It passes its own CRUCIBLE_* variables to the runs that it launches. Export CRUCIBLE_INFERENCE before you start it, and give the key variable the same prefix ("key_env":"CRUCIBLE_DECISION_KEY").

examples/route is the runnable pack. It has five sample tickets and instructions to serve DiffusionGemma yourself.

Send work back

A task with revise = <task> or revise = [<task>, ...] is a reviewer. If it fails and rounds remain, the tasks that it names run again with the verdict. Then the reviewer runs again.

author = agent(
    name = "author",
    prompt = "Write PROBE.md, a probe that demonstrates the reported bug.",
    session = "author",
    emits_files = ["PROBE.md"],
)

review = command(
    name = "review",
    run = "./check.sh",
    depends_on = [author],
    emits_files = ["evidence/review.json"],
    revise = author,
    max_rounds = 3,
)

From the second round, the inputs of the author contain the last verdict of the reviewer under revision. The engine stages the declared files of the reviewer under inputs/review/:

{"round": 2, "max_rounds": 3, "reviewer": "review",
 "review": {"status": "fail", "note": "exit 1: ", "files": true,
            "output": {"accepted": false, "why": "the probe hit an unrelated 400"}}}
  • Rounds have a limit. max_rounds is 2 to 5. It has no default.
  • Each round settles. A passing round makes a commit. The engine discards a failing round. The session log records author[round-1], review[round-1], and so on. The verdict uses the rows of the last round.
  • Sessions continue. An author with a session continues the same conversation in each round, so it knows what it already tried.
  • Chains. revise = [pick, build] sends back a chain. Every listed task runs again in dependency order, and then the reviewer runs. You must list every task between a target and the reviewer, so that no task in a round reads a stale result.
  • One loop per task. A loop cannot contain a fan-out. A task cannot be in two loops. Loops cannot be nested or chained.

examples/revise-loop runs a revise pair through a real OpenShell sandbox with a fake model.

Fan out

over = producer.field maps a task over a list that its dependency emits. There is one instance for each item:

scan = skill(
    name = "scan",
    skill = "skills/scan-issues",
    args = {"repo": param("repo")},
    emits = ["issues"],
)

triage = skill(
    name = "triage",
    skill = "skills/triage-issue",
    depends_on = [scan],
    over = scan.issues,
    max_fanout = 12,
    isolated = True,
    required = False,
    emits = ["classification", "severity"],
    emits_files = ["TRIAGE.md"],
)

roundup = command(
    name = "roundup",
    run = "python3 roundup.py",
    depends_on = [scan, triage],
    join = "passed",
    emits_files = ["REPORT.md"],
)

The item is the key of each instance. triage[123] is issue 123 in every retry and in every report row. An instance reads its own key from the reserved item input. max_fanout sets the maximum count, and the engine limit is 256. isolated = True runs the instances at the same time in private worktrees, at most eight at a time, and each one starts only while the run is under --max-cost. required = False lets the sweep finish when some instances fail. join = "passed" gives roundup only the instances that passed. examples/triage is the full pack.

If you know the list at compile time, write the fan-out in Starlark. It unrolls into normal tasks:

AUDITS = [("headings", True), ("bullets", True), ("freshness", False)]

def auditor(topic, blocking):
    return agent(
        name = "audit-" + topic,
        prompt = prompt_file("prompts/audit.md") + "\nAUDIT: " + topic.upper() + "\n",
        depends_on = [polish],
        isolated = True,
        required = blocking,
        emits = ["findings"],
    )

auditors = [auditor(topic, blocking) for topic, blocking in AUDITS]

Decide per item

A route can also map over a list. It asks one time for each item. A task mapped over the same list reads the answer for each item with when, so the turn for item k runs only if the answer for item k selects it:

prepare = command(name = "prepare", run = "./stage.sh",
                  emits = {"members": "list", "facts": "object"})

gate = route(
    name = "gate",
    depends_on = [prepare],
    over = prepare.members,
    max_fanout = 120,
    keyed = [prepare.facts],
    min_confidence = 0.8,
    questions = {"depth": choice(
        ask = "How much work does this ticket need?",
        options = {"settled": "the staged facts already answer it",
                   "quick": "a short look", "deep": "a full investigation"},
        drop = ["settled"],
    )},
)

quick = agent(name = "quick", prompt = prompt_file("analyze.md"), effort = "low",
              depends_on = [prepare, gate], over = prepare.members, max_fanout = 120,
              keyed = [prepare.facts], when = gate.depth, answers = "quick")
deep = agent(name = "deep", prompt = prompt_file("analyze.md"), effort = "high",
             depends_on = [prepare, gate], over = prepare.members, max_fanout = 120,
             keyed = [prepare.facts], when = gate.depth, otherwise = True)
  • Items pair by key. Tasks mapped over the same producer.field are aligned. quick[T-1] reads gate[T-1]. An aligned dependency gives an instance only its instance for the same key, not the whole fan-out.
  • Items that are not taken stay visible. quick[T-2] settles not_taken, with the answer in its note. It does not fail the fan-out. The fan-out output counts it under not_taken. An aligned task that joins all on it is also not taken.
  • One request for each item. max_fanout limits the number of requests. A resumed run keeps the decisions that it has and asks only for the others.
  • keyed limits the input. prepare.facts is an object keyed by item. Each instance, route or task, gets only its own entry under facts. A fan-out of one hundred items does not send the facts of every item to every turn.
  • Judge a result file. files = ["RESULT.json"] puts the declared JSON file of a dependency in the model state under files. A route mapped after the analysis can ask if the evidence supports the verdict, and use when to send uncertain items to a review lane.
  • Deterministic route first. A route mapped over the list can read each answer from an output object keyed by item ({"reachable": {"T-1": "no"}}). Put that route first, and give the model route a when on it. The model then gets questions only about the items that the facts do not decide.

Choose among an item's own options

A choice may take its options from a dependency instead of the pack. options = prepare.packages reads the field at each decision: the list itself on an unmapped route, or, on a route with over, the item's entry in an object keyed by item.

match = route(
    name = "match",
    depends_on = [prepare],
    over = prepare.members,
    max_fanout = 120,
    min_confidence = 0.7,
    questions = {"package": choice(ask = "Which shipped package does the flaw affect?",
                                   options = prepare.packages)},
)
analyze = agent(name = "analyze", prompt = prompt_file("analyze.md"),
                depends_on = [prepare, match], over = prepare.members, max_fanout = 120,
                when = match.package, otherwise = True)
review = agent(name = "review", prompt = prompt_file("review.md"),
               depends_on = [prepare, match], over = prepare.members, max_fanout = 120,
               when = match.package, answers = "uncertain")

prepare emits {"packages": {"T-1": [{"value": "p3", "description": "golang.org/x/net 0.17.0"}, {"value": "none", "description": "No shipped package matches"}]}}.

  • Labels are identifiers. Put raw text such as a package name in description and a short key in value. An option every item should have, like none, is the producer's to emit.
  • Bounded. At most 64 options; a repeated label keeps its first entry. An item whose list is malformed or holds fewer than two options fails without a request, and the rest decide.
  • Branch on uncertain, read the pick. The labels are unknown when the pack compiles, so a when lists only "uncertain", and otherwise = True covers every option. The task it gates reads which option was chosen, and the options it was chosen among, from the route's output.

Keep a conversation

Agent tasks that share a session continue one conversation in dependency order:

scribe = session(name = "scribe")

draft = agent(name = "draft", prompt = prompt_file("prompts/draft.md"), session = scribe, emits = ["entries"])
shape = command(name = "shape", run = "./shape.sh", depends_on = [draft])
polish = agent(name = "polish", prompt = prompt_file("prompts/polish.md"), session = scribe, depends_on = [shape])

polish continues from where draft stopped. Each pair of tasks in a session needs a dependency path between them. A session task cannot be isolated. session() can also set the default harness, model, and effort for its tasks.

shape, between the two turns, shows a second pattern: a command as a gate. It reads the declared entries of draft from CRUCIBLE_INPUTS, counts the files on disk, and exits nonzero if the numbers are different. A bad draft does not get a second, paid turn.

Joins

The join of a task sets what it needs from its dependencies before it runs:

joinRuns whenReceives
all (default)Every dependency passed.Every dependency's output.
passedEvery dependency is terminal and at least one passed.Only the passing ones.
settledEvery dependency is terminal, whatever it settled as.Each one as {status, note, output, files}.

Use passed to join branches and to collect fan-outs. Use settled for a task that must see failures, for example a summary that reports which tasks failed.

Advisory tasks

required = False makes a task advisory. If it fails, its dependents are blocked, but the run can still be valid. Use it for useful checks that must not fail the result: a freshness audit, a slow benchmark, one instance of a sweep.

A required task can be unable to run on the substrate, because the substrate does not declare its needs capability. In that case, the engine truncates the whole plan before it dispatches any task. Without a required task, the graph cannot produce a valid pass, so the engine does not start it.

Report the result

report() is a task that the engine owns. It publishes a rendered template to a destination that the controller configures:

publish = report(
    name = "publish-report",
    destination = {"kind": "slack"},
    template = "reports/slack.md.j2",
    result = roundup,
    required = True,
)

The workflow names a destination key. It does not name a URL, channel, or credential. result puts only the declared fields of that task into the message. An agent cannot skip the call or write the payload.

The template is a Jinja file in the pack. The engine renders it into the card body. The template can read these values:

NameValue
verdictthe run's verdict: "pass" when every required main-graph task passed or was not taken, otherwise "fail". Advisory tasks and branches not taken never change it
spent_usdtotal task cost
passed, failedcounts of settled tasks that passed and that did not, advisory and not-taken tasks included
tasksthe first 20 settled tasks, each with name, status, cost_usd
run, run_urlthe run name and its Crucible link
result.name, result.statusthe selected task and its terminal status
result.outputthe selected task's declared fields, with their JSON types; defined only when it passed

A field that the selected task did not declare is not defined. Guard optional reads with is defined. The engine escapes every inserted value for Slack, also with |safe. A result that holds <!channel> shows as that literal text. If the body is larger than CRUCIBLE_REPORT_BODY_MAX_BYTES (default and maximum 3000), the report task fails.

{% if result.output is defined %}{{ result.output.summary }}{% else %}roundup {{ result.status }}{% endif %}

severity_field names one declared field of result. Its value sets the accent color of the card:

publish = report(
    name = "publish-report",
    destination = {"kind": "slack"},
    template = "reports/slack.md.j2",
    result = roundup,
    severity_field = "severity",
)

"good", "warning", and "danger" set the accent for any verdict. The card shows the verdict on a separate line. Any other value gives a neutral accent. A selected task that was skipped or not taken also gives a neutral accent. A selected task that failed, had a transport failure, or was blocked gives "danger". The field sets only the accent, not part of the payload. It is a compile error to set severity_field without result, or to name a field that result does not declare in emits.

Where to look next

Workflow DSL reference

The dialect is Starlark with assignments, if, for, comprehensions, def, lambda, load(), at the top level or inside a def. Every one of them runs at compile time: the compiled plan is a static graph, so a loop in the source unrolls into tasks rather than becoming a cycle.

prompt_file(), schema_file(), and load() are the only file access, all confined below the pack directory, and a loaded module cannot re-export what it loaded. The surface has no processes, network access, clock, or randomness.

For what the engine does with the compiled graph, see Work graphs; for the normative rules, see the implementation contract.

Every lane

Available in every workflow type, playbooks included.

agent()

An agent turn driven by a prompt.

ArgumentTypePurpose
namestrTask identity, unique within the workflow.
promptstrThe turn's prompt.
harnessstrAgent harness, overriding [agent].
modelstrModel, overriding [agent].
effortstrReasoning effort, overriding [agent].
sandboxstrAn [agent.sandbox.<name>]: its image, and only the secrets, relay files, broker reach, and egress it lists. Unset runs in the [agent] sandbox.
sessionsession | strJoin a durable conversation. A task in a session cannot be isolated.
repairintRepair turns, up to 3. When a passing turn's output misses a declared field or file, or breaks its type or schema, the same session is resumed with the masked validation notes and asked to fix it in place, then checked again. Repairs share the attempt's timeout and cost. Default 0; refused on command and evaluate.
depends_onlist[task | optional(task)]Dependencies. Readiness decides execution order; declaration order does not.
needsstrThe substrate capability the task needs, such as "decision". The default "any" runs everywhere. A required task whose capability is unavailable truncates the plan before dispatch; an advisory one is skipped with its dependents.
join"all" | "passed" | "settled"Which dependencies must have passed: all every one, passed at least one and only those are forwarded, settled none — it dispatches once every dependency is terminal, whatever it settled as, unless the run has already halted, and forwards each one as {status, note, output, files}.
requiredboolFalse makes the task advisory: it blocks dependents but cannot invalidate the run.
isolatedboolRun in a disposable worktree. File changes are discarded; only JSON output continues.
emitslist[str] | dict[str, type]Result fields the task promises in its JSON output. The dict form also promises each field's type: "string", "integer", "number", "boolean", "list", "object", "link", "links", or a list of labels the value is one of. A passing output missing a field, or holding one of the wrong type, fails the task. Types are checked where the graph reads them: over needs a list, a top_k or grade score a number, and a route(source = ...) question labels it can answer. A link is one http(s) url and links a list of them, each naming a result the task produced outside the run (a pull request, a pushed branch, an issue), which the run and task views then link out to. A schema_file(...) promises a value its JSON Schema admits, and is checked against each consumer exactly: over needs "type": "array" with string items (by type, const or enum), a score a numeric type with no non-number in its enum, and a route a const, enum or boolean whose every value it can answer. A when label such a source can never give is refused.
emits_fileslist[str] | dict[str, schema | None]Workspace files the task produces. A task is staged with the declared files of every ancestor that passed, not only its direct dependencies, under inputs/<producer>/; a join = "settled" task also gets those of a direct dependency that failed. JSON outputs, unlike files, come from direct dependencies only. The dict form maps a path to schema_file(...) or None; a file with a schema must hold JSON the schema admits when the task passes, or the task fails.
overproducer.fieldMap the task over a dependency's emitted list, one instance per item.
max_fanoutintInstance cap for over, within the engine's ceiling of 256.
keyedlist[producer.field]Object fields of unmapped dependencies, keyed by item. Each instance receives only its own item's entry in place of the whole field. Needs over.
revisetask | list[task]The tasks this task sends back when it settles failing. They run again, in dependency order, with the verdict under revision, then this task does, until this task stops failing or max_rounds is spent. Every task on a path between them and this task must be listed. Playbooks only; not with over.
max_roundsintRound cap for revise, counting the first, from 2 to the engine's ceiling of 5.
timeoutstrHow long one attempt may run, as 90s, 10m, or 2h. At the limit the attempt's whole process group is killed and the task settles failed with a note naming the limit; it is not retried. The run's --max-time bounds every attempt as well, and a playbook refuses a timeout longer than it before dispatching anything.
stage"iteration" | "epilogue"epilogue runs once after the loop concludes, and only if the run kept a candidate.
historyintRead this many earlier runs of the launch series, from 1 to the engine's ceiling of 30, under history. A run launched outside a series gets an empty list. Playbooks only.
whenroute.questionRun only on a listed answer to one question of a route() this task depends on. Otherwise the task settles not_taken: no dispatch, no spend, no effect on validity, and every all-join dependent is not taken with it unless it lists the task as optional(...). Rejoin branches with join = "passed" or join = "settled". Playbook and custom workflows only.
answersstr | list[str]The answers when accepts: labels the question declares, or "uncertain". Defaults to "yes" for a noul and is required for a choice or a score. A choice whose options come from a field declares none, so only "uncertain".
otherwiseboolIn place of answers: every answer no other task lists and the question does not drop, "uncertain" included. Expanded at compile time. An unreachable otherwise is an error. On a choice whose options come from a field, it also stands for every option the choice resolves to, and is the only way to route them.

skill()

An agent turn whose prompt is a skill's instructions plus its arguments.

ArgumentTypePurpose
namestrTask identity, unique within the workflow.
skillstrSkill directory below the pack; its instructions become the prompt.
argsdictArguments appended to the instructions.
harnessstrAgent harness, overriding [agent].
modelstrModel, overriding [agent].
effortstrReasoning effort, overriding [agent].
sandboxstrAn [agent.sandbox.<name>]: its image, and only the secrets, relay files, broker reach, and egress it lists. Unset runs in the [agent] sandbox.
sessionsession | strJoin a durable conversation. A task in a session cannot be isolated.
repairintRepair turns, up to 3. When a passing turn's output misses a declared field or file, or breaks its type or schema, the same session is resumed with the masked validation notes and asked to fix it in place, then checked again. Repairs share the attempt's timeout and cost. Default 0; refused on command and evaluate.
depends_onlist[task | optional(task)]Dependencies. Readiness decides execution order; declaration order does not.
needsstrThe substrate capability the task needs, such as "decision". The default "any" runs everywhere. A required task whose capability is unavailable truncates the plan before dispatch; an advisory one is skipped with its dependents.
join"all" | "passed" | "settled"Which dependencies must have passed: all every one, passed at least one and only those are forwarded, settled none — it dispatches once every dependency is terminal, whatever it settled as, unless the run has already halted, and forwards each one as {status, note, output, files}.
requiredboolFalse makes the task advisory: it blocks dependents but cannot invalidate the run.
isolatedboolRun in a disposable worktree. File changes are discarded; only JSON output continues.
emitslist[str] | dict[str, type]Result fields the task promises in its JSON output. The dict form also promises each field's type: "string", "integer", "number", "boolean", "list", "object", "link", "links", or a list of labels the value is one of. A passing output missing a field, or holding one of the wrong type, fails the task. Types are checked where the graph reads them: over needs a list, a top_k or grade score a number, and a route(source = ...) question labels it can answer. A link is one http(s) url and links a list of them, each naming a result the task produced outside the run (a pull request, a pushed branch, an issue), which the run and task views then link out to. A schema_file(...) promises a value its JSON Schema admits, and is checked against each consumer exactly: over needs "type": "array" with string items (by type, const or enum), a score a numeric type with no non-number in its enum, and a route a const, enum or boolean whose every value it can answer. A when label such a source can never give is refused.
emits_fileslist[str] | dict[str, schema | None]Workspace files the task produces. A task is staged with the declared files of every ancestor that passed, not only its direct dependencies, under inputs/<producer>/; a join = "settled" task also gets those of a direct dependency that failed. JSON outputs, unlike files, come from direct dependencies only. The dict form maps a path to schema_file(...) or None; a file with a schema must hold JSON the schema admits when the task passes, or the task fails.
overproducer.fieldMap the task over a dependency's emitted list, one instance per item.
max_fanoutintInstance cap for over, within the engine's ceiling of 256.
keyedlist[producer.field]Object fields of unmapped dependencies, keyed by item. Each instance receives only its own item's entry in place of the whole field. Needs over.
revisetask | list[task]The tasks this task sends back when it settles failing. They run again, in dependency order, with the verdict under revision, then this task does, until this task stops failing or max_rounds is spent. Every task on a path between them and this task must be listed. Playbooks only; not with over.
max_roundsintRound cap for revise, counting the first, from 2 to the engine's ceiling of 5.
timeoutstrHow long one attempt may run, as 90s, 10m, or 2h. At the limit the attempt's whole process group is killed and the task settles failed with a note naming the limit; it is not retried. The run's --max-time bounds every attempt as well, and a playbook refuses a timeout longer than it before dispatching anything.
stage"iteration" | "epilogue"epilogue runs once after the loop concludes, and only if the run kept a candidate.
historyintRead this many earlier runs of the launch series, from 1 to the engine's ceiling of 30, under history. A run launched outside a series gets an empty list. Playbooks only.
whenroute.questionRun only on a listed answer to one question of a route() this task depends on. Otherwise the task settles not_taken: no dispatch, no spend, no effect on validity, and every all-join dependent is not taken with it unless it lists the task as optional(...). Rejoin branches with join = "passed" or join = "settled". Playbook and custom workflows only.
answersstr | list[str]The answers when accepts: labels the question declares, or "uncertain". Defaults to "yes" for a noul and is required for a choice or a score. A choice whose options come from a field declares none, so only "uncertain".
otherwiseboolIn place of answers: every answer no other task lists and the question does not drop, "uncertain" included. Expanded at compile time. An unreachable otherwise is an error. On a choice whose options come from a field, it also stands for every option the choice resolves to, and is the only way to route them.

command()

A deterministic shell task in the candidate workspace.

ArgumentTypePurpose
namestrTask identity, unique within the workflow.
runstrThe command, run through sh -c.
depends_onlist[task | optional(task)]Dependencies. Readiness decides execution order; declaration order does not.
needsstrThe substrate capability the task needs, such as "decision". The default "any" runs everywhere. A required task whose capability is unavailable truncates the plan before dispatch; an advisory one is skipped with its dependents.
join"all" | "passed" | "settled"Which dependencies must have passed: all every one, passed at least one and only those are forwarded, settled none — it dispatches once every dependency is terminal, whatever it settled as, unless the run has already halted, and forwards each one as {status, note, output, files}.
requiredboolFalse makes the task advisory: it blocks dependents but cannot invalidate the run.
isolatedboolRun in a disposable worktree. File changes are discarded; only JSON output continues.
emitslist[str] | dict[str, type]Result fields the task promises in its JSON output. The dict form also promises each field's type: "string", "integer", "number", "boolean", "list", "object", "link", "links", or a list of labels the value is one of. A passing output missing a field, or holding one of the wrong type, fails the task. Types are checked where the graph reads them: over needs a list, a top_k or grade score a number, and a route(source = ...) question labels it can answer. A link is one http(s) url and links a list of them, each naming a result the task produced outside the run (a pull request, a pushed branch, an issue), which the run and task views then link out to. A schema_file(...) promises a value its JSON Schema admits, and is checked against each consumer exactly: over needs "type": "array" with string items (by type, const or enum), a score a numeric type with no non-number in its enum, and a route a const, enum or boolean whose every value it can answer. A when label such a source can never give is refused.
emits_fileslist[str] | dict[str, schema | None]Workspace files the task produces. A task is staged with the declared files of every ancestor that passed, not only its direct dependencies, under inputs/<producer>/; a join = "settled" task also gets those of a direct dependency that failed. JSON outputs, unlike files, come from direct dependencies only. The dict form maps a path to schema_file(...) or None; a file with a schema must hold JSON the schema admits when the task passes, or the task fails.
overproducer.fieldMap the task over a dependency's emitted list, one instance per item.
max_fanoutintInstance cap for over, within the engine's ceiling of 256.
keyedlist[producer.field]Object fields of unmapped dependencies, keyed by item. Each instance receives only its own item's entry in place of the whole field. Needs over.
revisetask | list[task]The tasks this task sends back when it settles failing. They run again, in dependency order, with the verdict under revision, then this task does, until this task stops failing or max_rounds is spent. Every task on a path between them and this task must be listed. Playbooks only; not with over.
max_roundsintRound cap for revise, counting the first, from 2 to the engine's ceiling of 5.
timeoutstrHow long one attempt may run, as 90s, 10m, or 2h. At the limit the attempt's whole process group is killed and the task settles failed with a note naming the limit; it is not retried. The run's --max-time bounds every attempt as well, and a playbook refuses a timeout longer than it before dispatching anything.
stage"iteration" | "epilogue"epilogue runs once after the loop concludes, and only if the run kept a candidate.
historyintRead this many earlier runs of the launch series, from 1 to the engine's ceiling of 30, under history. A run launched outside a series gets an empty list. Playbooks only.
whenroute.questionRun only on a listed answer to one question of a route() this task depends on. Otherwise the task settles not_taken: no dispatch, no spend, no effect on validity, and every all-join dependent is not taken with it unless it lists the task as optional(...). Rejoin branches with join = "passed" or join = "settled". Playbook and custom workflows only.
answersstr | list[str]The answers when accepts: labels the question declares, or "uncertain". Defaults to "yes" for a noul and is required for a choice or a score. A choice whose options come from a field declares none, so only "uncertain".
otherwiseboolIn place of answers: every answer no other task lists and the question does not drop, "uncertain" included. Expanded at compile time. An unreachable otherwise is an error. On a choice whose options come from a field, it also stands for every option the choice resolves to, and is the only way to route them.

evaluate()

A measurement command. Its last non-empty stdout line is a JSON object; pass = false vetoes the result and numeric score feeds grade() and top_k().

ArgumentTypePurpose
namestrTask identity, unique within the workflow.
runstrThe command, run through sh -c.
thresholdnumberGrade the emitted score against this bound. pass = false fails a score within it; pass cannot pass a score outside it.
direction"lower" | "higher"Which side of the threshold passes.
depends_onlist[task | optional(task)]Dependencies. Readiness decides execution order; declaration order does not.
needsstrThe substrate capability the task needs, such as "decision". The default "any" runs everywhere. A required task whose capability is unavailable truncates the plan before dispatch; an advisory one is skipped with its dependents.
join"all" | "passed" | "settled"Which dependencies must have passed: all every one, passed at least one and only those are forwarded, settled none — it dispatches once every dependency is terminal, whatever it settled as, unless the run has already halted, and forwards each one as {status, note, output, files}.
requiredboolFalse makes the task advisory: it blocks dependents but cannot invalidate the run.
isolatedboolRun in a disposable worktree. File changes are discarded; only JSON output continues.
emitslist[str] | dict[str, type]Result fields the task promises in its JSON output. The dict form also promises each field's type: "string", "integer", "number", "boolean", "list", "object", "link", "links", or a list of labels the value is one of. A passing output missing a field, or holding one of the wrong type, fails the task. Types are checked where the graph reads them: over needs a list, a top_k or grade score a number, and a route(source = ...) question labels it can answer. A link is one http(s) url and links a list of them, each naming a result the task produced outside the run (a pull request, a pushed branch, an issue), which the run and task views then link out to. A schema_file(...) promises a value its JSON Schema admits, and is checked against each consumer exactly: over needs "type": "array" with string items (by type, const or enum), a score a numeric type with no non-number in its enum, and a route a const, enum or boolean whose every value it can answer. A when label such a source can never give is refused.
emits_fileslist[str] | dict[str, schema | None]Workspace files the task produces. A task is staged with the declared files of every ancestor that passed, not only its direct dependencies, under inputs/<producer>/; a join = "settled" task also gets those of a direct dependency that failed. JSON outputs, unlike files, come from direct dependencies only. The dict form maps a path to schema_file(...) or None; a file with a schema must hold JSON the schema admits when the task passes, or the task fails.
overproducer.fieldMap the task over a dependency's emitted list, one instance per item.
max_fanoutintInstance cap for over, within the engine's ceiling of 256.
keyedlist[producer.field]Object fields of unmapped dependencies, keyed by item. Each instance receives only its own item's entry in place of the whole field. Needs over.
revisetask | list[task]The tasks this task sends back when it settles failing. They run again, in dependency order, with the verdict under revision, then this task does, until this task stops failing or max_rounds is spent. Every task on a path between them and this task must be listed. Playbooks only; not with over.
max_roundsintRound cap for revise, counting the first, from 2 to the engine's ceiling of 5.
timeoutstrHow long one attempt may run, as 90s, 10m, or 2h. At the limit the attempt's whole process group is killed and the task settles failed with a note naming the limit; it is not retried. The run's --max-time bounds every attempt as well, and a playbook refuses a timeout longer than it before dispatching anything.
stage"iteration" | "epilogue"epilogue runs once after the loop concludes, and only if the run kept a candidate.
historyintRead this many earlier runs of the launch series, from 1 to the engine's ceiling of 30, under history. A run launched outside a series gets an empty list. Playbooks only.
whenroute.questionRun only on a listed answer to one question of a route() this task depends on. Otherwise the task settles not_taken: no dispatch, no spend, no effect on validity, and every all-join dependent is not taken with it unless it lists the task as optional(...). Rejoin branches with join = "passed" or join = "settled". Playbook and custom workflows only.
answersstr | list[str]The answers when accepts: labels the question declares, or "uncertain". Defaults to "yes" for a noul and is required for a choice or a score. A choice whose options come from a field declares none, so only "uncertain".
otherwiseboolIn place of answers: every answer no other task lists and the question does not drop, "uncertain" included. Expanded at compile time. An unreachable otherwise is an error. On a choice whose options come from a field, it also stands for every option the choice resolves to, and is the only way to route them.

report()

Publish a rendered template to a controller-configured destination. The workflow selects a destination key, never an endpoint or a credential.

ArgumentTypePurpose
namestrTask identity, unique within the workflow.
destinationdictThe sink to publish to, as {"kind": "slack"}. The controller holds its endpoint and credential.
templatestrPack file rendered into the card body. Reads verdict, spent_usd, passed, failed, tasks (first 20), run, run_url, and result. Every inserted value is escaped for Slack.
resulttaskThe task whose declared fields the card and result.output carry; result.status alone when it did not pass.
severity_fieldstrA declared field of result whose value ("good", "warning", "danger") picks the card accent; any other value is neutral.
requiredboolFalse makes the report advisory.

session()

Declare a durable agent conversation. Tasks that share one run serially under one agent config, across dependency order and loop iterations.

ArgumentTypePurpose
namestrSession identity, referenced by session =.
harnessstrDefault harness for tasks in the session.
modelstrDefault model for tasks in the session.
effortstrDefault effort for tasks in the session.

param()

Read a launch parameter. The params block must be the source's first statement, and a source that declares one compiles per run. A supplied value may reach a prompt, with + and inside a region marked as external input, or a skill argument; str(), %, .format(), and string methods on it are refused, as is a value carrying the marker text. A command or evaluate task reads it from params in the inputs JSON at CRUCIBLE_INPUTS_FILE instead of its command line.

Takes one positional argument, name.

prompt_file()

Embed a UTF-8 file below the pack directory. Absolute paths, .., symlinks, non-files, and oversized inputs are refused.

Takes one positional argument, path.

schema_file()

Read a JSON Schema (draft 2020-12) below the pack directory, for a field type in emits or a file in emits_files. Its content, not its path, is compiled into the plan. Paths are confined as for prompt_file; a file that is not a valid 2020-12 schema, declares another $schema, or references a remote $ref is refused.

Takes one positional argument, path.

workflow()

The source's final expression: the lane, the tasks that ship, and the result. A task constructed but not listed is a compile error.

Takes one positional argument, tasks.

ArgumentTypePurpose
type"autoresearch" | "custom" | "playbook"The lane, which decides which constructors exist.
taskslist[task]Every task that ships.
resulttaskThe task whose output is the workflow's result.
history_recordtaskThe task each run records for the later runs of its launch series: its status, and the fields it declares in emits when it passed. Not a mapped task; an epilogue task records even when the main graph failed. Playbooks only.

Scored lanes only

Available to type = "autoresearch" and type = "custom". A playbook does not have these in scope at all, so naming one is an unknown-name error and a did-you-mean never offers one.

propose()

The loop's candidate-producing agent turn.

ArgumentTypePurpose
namestrTask identity, unique within the workflow.
sessionsession | strThe conversation the turn belongs to.
depends_onlist[task]Dependencies.

apply()

Make the candidate live through the configured world. A failure means unscoreable, not worse.

ArgumentTypePurpose
namestrTask identity, unique within the workflow.
depends_onlist[task]Dependencies.

measure()

Run the manifest's frozen judge as one opaque measurement task.

ArgumentTypePurpose
namestrTask identity, unique within the workflow.
depends_onlist[task]Dependencies.

grade()

Fold evaluation evidence into a measurement. Evidence includes tasks that failed or never ran, which is what the score source alone cannot see.

ArgumentTypePurpose
namestrTask identity, unique within the workflow.
scoretaskThe task whose score the decision uses.
tiebreaktaskSecondary score that breaks primary-score ties.
evidencelist[task]Tasks folded into the measurement.
join"all" | "passed"Which evidence must have passed.

decide()

Apply the engine's keep-or-discard rule to a measurement. An autoresearch workflow must end here.

ArgumentTypePurpose
namestrTask identity, unique within the workflow.
measurementtaskThe measurement being ruled on.
depends_onlist[task]Dependencies, defaulting to the measurement.

top_k()

Engine-owned reducer: the best k dependency outputs by numeric score.

ArgumentTypePurpose
namestrTask identity, unique within the workflow.
kintHow many dependencies survive.
direction"lower" | "higher"Which score wins.
depends_onlist[task]The candidates being reduced.
requiredboolFalse makes the reducer advisory.

default_autoresearch()

Expand the built-in propose/apply/measure/decide loop into visible nodes, plus the tasks passed to it.

Takes one positional argument, extra_tasks.

Playbook and custom lanes only

Available to type = "playbook" and type = "custom". An autoresearch workflow keeps or discards on a frozen measure, so it has neither these nor when.

route()

Engine-owned decision: answers typed questions about its dependencies' outputs and records one label per question, which other tasks branch on with when. Every label of a question some when refers to, "uncertain" included, must be listed by a when or by the question's drop.

ArgumentTypePurpose
namestrTask identity, unique within the workflow.
questionsdict[str, question]Question id to noul(), choice(), or score(). gate.<id> names one for when.
min_confidencenumberA decision model answers, through the broker's decision capability. An answer whose probability is below this, in (0, 1], is recorded as "uncertain". Exactly one of min_confidence and source.
sourcetaskA dependency's output answers instead: it emits one declared label (or a boolean, for a noul) under each question id. Deterministic, free, and needs no capability. Any other value fails the route. When the dependency types its emits, each question's field must be typed with labels the question answers, or "boolean" for a noul.
humanboolTrue: a person allowed to approve the run answers, from the controller UI, after seeing the dependencies' declared outputs and files. Needs the human capability. Tasks that do not depend on the route keep running while it waits.
reviewpathWith human: a Markdown template in the pack, rendered over the evidence (inputs, run, gated) and shown above it. Every inserted value is escaped.
timeoutdurationWith human: how long the request stays open, from when it opens; the run's wall-clock ceiling by default. An unanswered request records every question as "uncertain".
fileslist[str]Declared JSON files of dependencies the model reads, under files in its state, by dependency and path. Each must be declared by a dependency with a schema_file(...). Not with source.
overproducer.fieldDecide once per item. A task mapped over the same list reads each item's answer with when; one mapped over another list, or not mapped, cannot.
max_fanoutintInstance cap for over, within the engine's ceiling of 256.
keyedlist[producer.field]Object fields of unmapped dependencies, keyed by item. Each instance receives only its own item's entry in place of the whole field. Needs over.
depends_onlist[task]Dependencies. Their outputs are the state the questions are asked about.
requiredboolFalse makes the route advisory.
join"all" | "passed" | "settled"Which dependency outputs form the state, as on any task.
stage"iteration" | "epilogue"As on any task.
whenroute.questionRun only on a listed answer to one question of a route() this task depends on. Otherwise the task settles not_taken: no dispatch, no spend, no effect on validity, and every all-join dependent is not taken with it unless it lists the task as optional(...). Rejoin branches with join = "passed" or join = "settled". Playbook and custom workflows only.
answersstr | list[str]The answers when accepts: labels the question declares, or "uncertain". Defaults to "yes" for a noul and is required for a choice or a score. A choice whose options come from a field declares none, so only "uncertain".
otherwiseboolIn place of answers: every answer no other task lists and the question does not drop, "uncertain" included. Expanded at compile time. An unreachable otherwise is an error. On a choice whose options come from a field, it also stands for every option the choice resolves to, and is the only way to route them.

noul()

A yes/no question for route(). It answers "yes" or "no".

ArgumentTypePurpose
askstrWhat to decide.
dropstr | list[str]Answers that deliberately lead nowhere, so no when has to list them.

choice()

A one-of-N question for route().

ArgumentTypePurpose
askstrWhat to decide.
optionslist[str] | dict[str, str | None] | producer.fieldAt least two distinct identifier labels, optionally each with a description the model sees. "uncertain" is reserved. Or an unmapped dependency's field, read at each decision: the list itself, or, on a route with over, an object keyed by item holding each item's list. Each entry is a label or {"value": label, "description": str}; at most 64, a repeated label keeps its first entry, and fewer than two fails that decision. The answer records the options it chose among. Needs min_confidence.
multipleboolTake one or more labels. Only a human = True route asks it; a when runs if any listed label was chosen.
dropstr | list[str]Answers that deliberately lead nowhere, so no when has to list them.

pick()

A question for a human = True route whose options are a list a dependency produced. The route's output holds the picked values under the question id, so over = gate.question fans out over them. No when routes on it.

ArgumentTypePurpose
askstrWhat to pick.
sourceproducer.fieldA list of strings in the output of a task the route depends on, read when the route opens its request.
multipleboolTake one or more values rather than one.

score()

An ordinal question for route(). It answers its most probable level, the lowest on a tie, and records a score in [0, 1]: the expected level position over the normalized distribution, 0 for the lowest level and 1 for the highest. A source answers one level label, scored by its position, and "uncertain" records no score.

ArgumentTypePurpose
askstrWhat to decide.
levelslist[str] | dict[str, str | None]At least two distinct identifier labels, lowest first in the order written, optionally each with a description the model sees. "uncertain" is reserved.
dropstr | list[str]Answers that deliberately lead nowhere, so no when has to list them.

optional()

Mark an entry of depends_on that may settle not_taken. Under join = "all" the task dispatches past it as though it passed, and its inputs carry no entry for it; one that failed or was blocked still blocks. Refused under another join, on a dependency that can never settle not_taken, and on a task the reviewer revises.

Takes one positional argument, task.

Reserved fields

Names the engine reads and writes for itself. They are not constructor arguments; they appear in a task's own JSON output and in the inputs it receives.

A task's own JSON output

Read out of the object the task returns.

FieldTypeMeaning
status"pass" | "fail" | "skipped""fail" or "skipped" settles the task that way, whatever its exit status, pass or threshold. "pass" cannot pass a task that exited nonzero or did not pass its grade. Any other value is ignored.
notestrWhy the task failed or was skipped, recorded as its note when it is a non-blank string: after the exit status in place of the stderr tail when a command exits nonzero, before the threshold and log handles when an evaluation does not pass, and in place of the default note when status is "fail" or "skipped". The control plane shows it as a parked run's reason. Read only from a result that fails or skips.
completebooltrue on a passing main-graph command or evaluate task of a playbook ends the run early and valid: nothing else in the main graph dispatches, undispatched tasks stay unsettled, and epilogue tasks still run. The shutdown outcome is complete. Any other task's true is refused on its row and the run continues. A task may not list it in emits.
reasonstrWhy the run completed early, recorded with the shutdown. A task may not list it in emits.

Inputs the engine writes

Present alongside the dependency entries, never wrapped in one.

FieldTypeMeaning
historyobjectEarlier terminal runs of the launch series, as {"records": [record], "dropped": int}, oldest first by end time, at most the task's history depth. Each record is {"run", "started_at", "ended_at", "outcome", "verdict", "revision", "link", "entry": {"task", "status", "output"}}. dropped counts records the operator's size limit removed from the oldest end. In a task that declares history only; an agent reads it as marked external input.
itemstrThis mapped instance's key, one per item of the list over names.
keptobjectThe kept candidate, in an epilogue task only.
outcomeobjectHow the main graph ended and what each of its tasks settled as, as {"exit": str, "tasks": {name: {"status", "note", "output", "files"}}}, in an epilogue task only: each entry is what a settled join receives, per_instance included for a mapped task. files says whether that task's declared files, passing or failing, are staged under inputs/<name>/.
paramsobjectEvery declared parameter's bound value under its name, in its declared type, and {} where the source declares none, in a command or evaluate task only.
revisionobjectThe verdict that sent this task back, as {"round": int, "max_rounds": int, "reviewer": str, "review": {"status", "note", "output", "files"}}, from the second round of a revise loop on.

Work graphs

A plan is a versioned DAG of tasks that a deterministic executor runs. A task is an agent turn, a command that the plan defines, or a reducer that the engine owns. The executor advances the plan. A task does not select the next task.

The engine supplies a default loop graph and a wide-tournament template. A person or a pack can supply a plan as TOML or JSON through the plan CLI. Workflow admission keeps the graph shape separate from authority. An orchestrator must declare each workflow type and each engine operation that it can execute safely.

Running a plan

# compile and print it, without executing
crucible plan show --file plan.toml
crucible plan show --file plan.toml --mermaid      # flowchart source
crucible plan show --file plan.toml --render       # PNG, inline if the terminal supports it

# execute: command tasks run as subprocesses, agent tasks through the real harness
crucible plan run --file plan.toml --manifest crucible.toml

# execute without a manifest: agent tasks run a stand-in command instead
crucible plan run --file plan.toml --agent-cmd ./role.sh

# replace the manifest's [agent] harness and model for this run; a task that pins its own keeps it
crucible plan run --manifest crucible.toml --harness codex --model gpt-5.6-luna

--cap <name> (repeatable) declares a capability of the substrate. See needs below.

plan run exits with a nonzero status if the plan does not reach a valid verdict.

File format

version = 1                 # the only format version accepted today
# reason = "..."            # reserved for a future replan protocol

[budget]
usd = 5.0                   # required, positive; execution fails closed on overrun

[params]                    # optional; command and evaluate tasks read it as inputs["params"]
topic = "slag"

[[task]]
name = "propose"            # unique within the plan
kind = "agent"
prompt = "..."
model = "claude-opus-4-6"   # optional per-task overrides of the manifest's [agent]
harness = "claude"
effort = "high"
session = "solver"         # optional durable logical conversation

[[task]]
name = "measure"
kind = "command"
command = "./bench.sh"
depends_on = ["propose"]
needs = "gpu"               # default "any"
required = true             # default true
isolation = "worktree"      # optional
join = "all"                # default "all"
emits = ["score"]           # optional declared output fields; absent = undeclared
                            # or typed: emits = { score = "number", tier = ["high", "low"] }
timeout = "20m"             # optional per-attempt limit; agent, command, and evaluate only

[[task]]
name = "pick"
kind = "top_k"
k = 1
direction = "lower"         # or "higher"
depends_on = ["measure"]

Task kinds

KindWhat it runs
agentOne agent turn. harness, model, and effort override the [agent] defaults of the manifest for the task. session enables durable continuation.
commandA command that the plan defines. It returns JSON on its last stdout line.
evaluateA measurement command. pass = false rejects the result. A threshold with a direction grades a numeric score.
top_kA reducer that the engine owns. It keeps the k best inputs by their score field. It needs at least one dependency.
engineAn operation that a capability owns (propose, apply, measure, grade, decide, or measure_diff). Only an orchestrator that admits it can execute it.

A workflow can declare and order engine nodes, but that does not give it authority to run them. Admission needs the matching capabilities, for example workflow.autoresearch, engine.apply, and engine.measure. The generic plan runner rejects engine nodes because it has no World and no frozen Judge.

A command string alone is not a trust boundary. If a command runs a script that must stay trusted after an agent task edits the workspace, declare that script as a frozen [[workspace.inject]] in the manifest. Before each task, the plan runner restores frozen injects in the workspace of the task. This includes isolated worktrees.

A logical session is serial state, so each pair of tasks that share a session must have a dependency path between them. A session task cannot use disposable worktree isolation. The private ledger keeps only an opaque harness cursor and the count of completed turns. The engine does not copy that cursor or the native Claude transcript into the plan or the public session log. Normal streamed harness events keep their usual visibility. The native Claude Code transcript stays in its private local store, or in a mode-0600 engine store that the engine restores into each new OpenShell sandbox. If you omit session, each turn starts a new conversation.

Task output

The output of a task is JSON. It becomes the input of the task's dependents.

  • command: the last non-empty stdout line. A nonzero exit status is a measured failure. A spawn failure is a transport failure. Upstream outputs arrive as a JSON object keyed by task name, in the file that CRUCIBLE_INPUTS_FILE names. CRUCIBLE_TASK gives the task name. The same JSON is also in CRUCIBLE_INPUTS if it is 64 KiB or smaller. If it is larger, the variable is not set, because Linux rejects an environment string larger than 128 KiB. Read the file. In an edition-2 pack (edition = 2 at the top of crucible.toml), the command gets besides these only PATH, HOME, LANG, TMPDIR, the run's identity (CRUCIBLE_RUN_NAME, CRUCIBLE_RUN_DISPLAY_NAME, CRUCIBLE_UI_BASE_URL), and the variables [workflow].env names in the manifest (env = ["GH_TOKEN", "GPUS?"]). A named variable the engine does not have refuses the run, unless its name ends in ?: an optional variable is passed when the engine has it and left out when it does not. In an edition-1 pack the command inherits the engine's whole environment, and [workflow].env fails the parse.
  • agent with --manifest: the turn writes one JSON object to PLAN_TASK_RESULT.json in the workspace root. If the file is missing after a normal turn, this is a measured failure. An explicit spawn, harness, or stream error is a transport failure and follows the retry policy.
  • agent with --agent-cmd: the stand-in gets CRUCIBLE_PROMPT, CRUCIBLE_HARNESS, CRUCIBLE_MODEL, and CRUCIBLE_EFFORT. It returns JSON on its last stdout line.
  • evaluate: needs a JSON object. pass = false fails the task, and a malformed pass also fails it. A threshold with a direction compares the numeric score (lower is <=, higher is >=). Without a threshold, a successful command passes unless pass is false.

The controller keeps each attempt's output with the run's task results, and a failing attempt's too, such as an evaluate's verdict with pass = false. crux graph <run> prints what each task's latest attempt emitted, crux graph <run> --json (GET /api/runs/{run_id}/graph) returns every attempt's output, and the run view's task panel shows it as the task's payload.

top_k reads a finite numeric score from each input, so an upstream task that ranks must emit a score. You can declare this: emits = ["score"] on an agent, command, or evaluate task names the fields that its JSON output must contain. Before the run starts, validation rejects these tasks if their declared emits do not include score: a top_k dependency, a grade score source or tiebreak, and a thresholded evaluate. At run time, if a passing attempt does not have a declared field, the engine changes it to a measured failure at the producing task. The engine does not retry it, and it blocks dependents. The error appears at the task that caused it, not downstream. An empty or absent emits declares nothing, and the engine does not check it. top_k and engine tasks cannot declare emits, because the engine defines their outputs.

emits can also declare the type of each field, as a dict from field name to type:

scan = command(
    name = "scan",
    run = "./scan.sh",
    emits = {"targets": "list", "count": "integer", "severity": ["high", "low"]},
)

A type is "string", "integer" (a number with no fractional part), "number", "boolean", "list", "object", or a list of labels. A list of labels declares a string that is equal to one of the labels. Labels are identifiers, as route labels are. The list form of emits is still valid, and it declares only that the field is present.

At run time, if a passing attempt has a field of the wrong type, or a string that is not one of its labels, it is a measured failure at the producing task. This is the same as a missing field. The note names the field, the value type that arrived, and the declared type. A task that settled itself as skipped or failed does not have to supply its fields.

At compile time, before any spend, the compiler checks the types at each place where the graph reads a field:

  • over = scan.targets needs a field declared "list". The error points to the over argument.
  • A top_k dependency, a grade score source or tiebreak, and a thresholded evaluate need score declared "number" or "integer".
  • route(source = scan, ...) needs the field of each question declared as labels that the question answers (its options, or yes/no, plus uncertain), or as "boolean" for a noul. The compiler rejects "string", because it does not limit which label arrives.

The compiler does not check a field declared without a type (list form) or a task without emits. The generated TOML holds the types as a table (emits = { score = "number" }). The plan_admitted event lists each field with its type.

Schema types

schema_file(path) reads a JSON Schema (draft 2020-12) from the pack. You can use it as a type in emits and in the dict form of emits_files:

plan = command(
    name = "plan",
    run = "./plan.sh",
    emits = {"lanes": schema_file("crucible/schemas/lanes.json"), "tickets": "integer"},
    emits_files = {"RESULT.json": schema_file("crucible/schemas/result.json"), "REPORT.md": None},
)

The compiler puts the schema content into the plan, so a change to the schema changes the plan digest. These are compile errors at the call, and the compiler fetches nothing: a file that is not a valid 2020-12 schema, a file that declares a different $schema, and a file that references a remote $ref. At run time, the producing task fails, as for a wrong type, in these cases: the schema rejects a field of a passing output, or a declared file is not JSON or does not match. The note gives the first three errors as instance path and message: output field "lanes" does not match its schema: /1: value is not of type "string". Notes do not repeat the rejected value.

Each consumer checks the schema exactly. It reads type, const, enum, local $ref, allOf, and every branch of anyOf/oneOf. It rejects a schema if it cannot prove the requirement:

  • over needs "type": "array" whose items (and any prefixItems) are strings that the schema can prove: "type": "string", or a const/enum of strings. The item names a mapped instance. For this reason, {"type": "array"} without items, or "items": {"type": "integer"}, is a compile error at the over argument.
  • A score needs a numeric type, and its const/enum must contain only numbers.
  • A route(source = ...) question needs every value that the schema limits (const, enum, or "type": "boolean", which answers yes/no) to be a label that the question accepts. The compiler rejects a schema that limits no value, as it rejects "string".

A route source can limit a question to a finite set of answers, with a label list, "boolean", or a schema. In that case, a when that names a label that the source cannot give is a compile error ("can never be answered"). Such a label needs no task, and otherwise covers only the labels that the source can give. A schema without one top-level type does not satisfy any consumer. On the wire, a schema is its JSON text ({ schema = "..." } in the TOML, { path = "RESULT.json", schema = "..." } for a file), because TOML cannot hold the null values that schemas often contain.

Repair

An agent task can repair its own output before the engine fails it:

plan = agent(
    name = "plan",
    prompt = prompt_file("prompts/plan.md"),
    emits = {"lanes": schema_file("crucible/schemas/lanes.json")},
    emits_files = {"RESULT.json": schema_file("crucible/schemas/result.json")},
    repair = 2,
)

A turn can pass but have output that misses a declared field or file, or does not match a type or schema. In that case, the engine continues the same conversation with the validation notes (masked as above). It tells the agent to repair the output and files in place, and then checks again. It does this up to repair times (at most 3).

Repairs run in the timeout of the attempt, and their cost counts against the run budget. A repair does not start if the attempt has already spent the remaining budget. Repairs are not retries: attempts stays 1. When no repairs remain, the task fails with the same note as without repairs. Each mapped instance repairs separately. A task without a session gets a conversation for the attempt. A task in a session continues that session. command and evaluate do not accept repair.

The task_result event of the task records each repair under repairs, as {label, round, of, cost_usd, notes}, with labels such as audit[RHAI-1] repair 1/2.

Execution semantics

Plan execution states is a generated diagram of how the executor walks a graph, the states of one task, and how the plan ends.

Readiness. A task dispatches when its dependencies are terminal and its join is satisfied. Dispatch follows declaration order, so the event stream is deterministic.

Truncation fails closed. If a required task cannot run on this substrate (its needs is not in the declared caps, or a dependency cannot run), the engine truncates the whole plan and dispatches no task. A truncated DAG cannot produce a valid pass. If an advisory task is in the same position, the engine skips it and its dependents, and validity does not change.

Failure. If a required task fails, the plan stops. All tasks that did not dispatch are blocked. An advisory failure blocks only its dependents.

Early completion. In a playbook, a passing main-graph command or evaluate task can return "complete": true, with an optional "reason" string. This ends the run. No other main-graph task dispatches. Tasks that did not dispatch stay unsettled, not blocked. Epilogue tasks still run. The run is valid unless a required task already settled with a status other than pass. The shutdown outcome is complete, with the reason. Both fields are reserved, so emits cannot list them. A revised command that completes ends its loop without another review. A mapped instance that completes ends the run after its node folds.

An agent task, or any other task whose output a model writes, cannot end the run this way. If it returns "complete": true, the engine refuses it: the task keeps its own outcome, its row's note records the refusal, and the run continues. Put the "nothing to do" decision in a command or evaluate task that reads the agent's output.

Retries apply only to transport failures. Transport failures retry, up to a limit (2 by default). A measured failure does not run again.

Budget. The cost of an attempt is known only after the attempt completes. For this reason, an attempt in progress can report a total larger than budget.usd. Any overrun makes the plan invalid and blocks all further dispatches and retries. A spend equal to the budget is valid only if no other retry or task is necessary.

Time. Elapsed time is known at all times, so it limits each attempt while the attempt runs. Each attempt has a deadline: the timeout of its task, counted from the start of the attempt, or the --max-time limit of the run if that is earlier. A task without timeout runs only under the run limit. At the deadline, the runner stops the whole process group of the attempt (a command and all processes that it started, or the agent turn). The task settles as a measured failure with a note that names the limit: timed out: the task ran past its 20m limit. The engine does not retry it, because a retry would use the same time again. A required task that times out stops the plan, as any other failure does. If the run limit ends an attempt, the run ends on that limit, and all tasks that did not dispatch settle as blocked. A playbook rejects a timeout longer than --max-time before it dispatches a task. In a concurrent batch, each task has its own deadline.

needs

The substrate capability that a task needs. "any" runs on all substrates. You must declare any other value with --cap. If you do not, the task cannot run. plan show reports the truncation verdict before any spend.

isolation

isolation = "worktree" gives the task a private clone of the workspace, with its uncommitted state. This has two effects:

  • Isolated tasks that are ready at the same time run concurrently. Without isolation, they would all write the one PLAN_TASK_RESULT.json in the shared workspace.
  • The engine discards the edits of the task. Only the declared output leaves the task. Use isolation for review and analysis. Do not use it for a task whose diff must be kept.

A runner that cannot isolate rejects the task. It does not run the task in the shared workspace.

join

join = "all" (default) needs every dependency to pass, except that a dependency marked optional(...) may also have settled not_taken. join = "passed" waits for every dependency, then collects the passing dependencies. There must be at least one. If no dependency can run or pass, the task fails.

revise

A reviewer sends a failing verdict back to the tasks that it names, for a limited number of rounds. Only playbooks support revise. It is the only repetition that the graph declares.

author = agent(
    name = "author",
    prompt = "Write PROBE.md, a probe that demonstrates the reported bug.",
    session = "author",
    emits_files = ["PROBE.md"],
)

review = command(
    name = "review",
    run = "./check.sh",
    depends_on = [author],
    emits_files = ["evidence/review.json"],
    revise = author,
    max_rounds = 3,
)

review runs against the draft as any dependent does. If it fails and rounds remain, author runs again, and then review runs again. This continues until review passes or the third round ends. Any other outcome of the reviewer ends the loop. The loop also ends if a revision fails and the reviewer is blocked. The engine checks the limits before each round.

revise also accepts a list, for a fix that needs more than one task to run again:

confirm = evaluate(
    name = "confirm",
    run = "python3 tools/probe.py --rung confirm",
    depends_on = [rig],
    revise = [pick, build, rig],
    max_rounds = 3,
)

The targets and the reviewer are the body of the loop. Each round runs the body one task at a time in dependency order. Each task uses its own join and when. If build fails, it blocks a reviewer with join = "all", and the loop ends. A reviewer with join = "settled" sees the failure and can send the chain back. The loop starts after every task outside the body that a body task reads has settled. A task outside the body that reads a body task waits until the loop ends.

From the second round, the inputs of each target contain the reserved revision key:

{"round": 2, "max_rounds": 3, "reviewer": "review",
 "review": {"status": "fail", "note": "exit 1: ", "files": true,
            "output": {"accepted": false, "why": "the probe hit an unrelated 400"}}}

files tells if the engine staged the declared files of the reviewer from that failing round. The engine stages them under inputs/<reviewer>/, where a join = "settled" consumer also finds them. The reviewer does not get revision. A body task that declares a session continues it in each round, so it knows what it already tried.

Each round settles separately. A passing round makes a commit, and the engine discards a failing round. Each round reports as task[round-N], the same naming that the instances of a mapped node use. When the loop ends, each task reports one row under its own name. The row holds its last round and the spend of all rounds. Only these rows decide the verdict. A dependent reads the last round.

The limit is 2 to 5, and it has no default. Validation rejects:

  • a target that the reviewer does not depend on, directly or through other tasks;
  • a body that omits a task on a path between two of its tasks (that task would read a result that a later round replaces);
  • a body task that is not an agent, command, or evaluate task, or that fans out;
  • a task in two bodies;
  • a reviewer that is also a target.

If the number of repetitions depends on what a task finds, put that repetition inside one task.

The loop as a plan

Each loop iteration runs as an autoresearch workflow that capabilities admit. Without an authored workflow, the default workflow expands to normal tasks:

flowchart LR
    propose["propose (engine)"] --> apply["apply"] --> measure["measure"] --> decide["decide"]

The author defines the task names and the tasks between the engine operations. The result of an autoresearch workflow must be a decision that a frozen measurement feeds, with apply and proposal ancestors. A custom workflow has no shape requirement. The outer orchestrator admits it only if it declares workflow.custom.

[workflow]
type = "autoresearch"
result = "keep-if-better"

[[workflow.task]]
name = "invent"
kind = "engine"
op = "propose"
session = "solver"

[[workflow.task]]
name = "review"
kind = "command"
command = "./review.sh"
depends_on = ["invent"]

[[workflow.task]]
name = "deploy-preview"
kind = "engine"
op = "apply"
depends_on = ["review"]

[[workflow.task]]
name = "benchmark"
kind = "engine"
op = "measure"
depends_on = ["deploy-preview"]

[[workflow.task]]
name = "keep-if-better"
kind = "engine"
op = "decide"
source = "benchmark"
depends_on = ["benchmark"]

To admit this workflow, the orchestrator needs workflow.autoresearch, engine.propose, engine.apply, engine.measure, and engine.decide. Because the workflow binds solver, it also needs agent.session.persist. A custom orchestrator can admit type = "custom" and any subset of the operations that it implements. Task-level needs still controls where an admitted task can run. Workflow capabilities control which operations the orchestrator is authorized to run.

Each iteration writes plan_admitted and task_result lines to the session log, in addition to the decision and session events. The loop driver keeps cross-round state, keep/discard, and all controls between rounds (parking, steering, re-scoping, budget). The wide round runs as a template that the engine compiles from [search].

The templates have no budget of their own. The driver owns the run budget and checks it between rounds, so the engine still measures and decides a turn that goes over the limit.

Authored measurement subgraphs

You can still use measure(). To make the measurement steps visible in the graph, use evaluate() and grade(). Dependencies define the rungs, isolated peers can run concurrently, and grade() selects the score source for decide().

candidate = propose(name = "invent")
live = apply(name = "deploy", depends_on = [candidate])

refcheck = evaluate(
    name = "cpu-reference",
    run = "./refcheck.sh",
    depends_on = [live],
)
diff = evaluate(
    name = "single-gpu-diff",
    run = "./diff.sh",
    depends_on = [refcheck],
    threshold = 0.001,
    direction = "lower",
    needs = "gpu",
    isolated = True,
)
latency = evaluate(
    name = "latency",
    run = "./latency.sh",
    depends_on = [diff],
    direction = "lower",
    threshold = 12.5,
    isolated = True,
)
racecheck = evaluate(
    name = "racecheck",
    run = "./racecheck.sh",
    depends_on = [diff],
    required = False,
    isolated = True,
)
measurement = grade(
    name = "final-grade",
    evidence = [diff, latency, racecheck],
    score = latency,
)
decision = decide(name = "choose", measurement = measurement)
workflow(
    type = "autoresearch",
    tasks = [candidate, live, refcheck, diff, latency, racecheck, measurement, decision],
    result = decision,
)

The renderer groups evaluate and grade as Measurement and keeps the edges. During crucible scope, validation writes the admitted graph to WORKFLOW.png. Agents cannot replace this file.

Run-scoped epilogue tasks

stage = "epilogue" on a [[workflow.task]] removes the task from the graph of each iteration. In Starlark, set stage = "epilogue" on agent(), command(), or evaluate(). The epilogue subgraph runs one time, after the loop ends cleanly (finished, budget, or solved). It runs against the final kept candidate, and only if the run kept a candidate. Use it for slow checks, for example a 90-minute compute-sanitizer racecheck or a slow performance benchmark.

The CRUCIBLE_INPUTS of each task holds the kept candidate under the reserved kept key: {"iter", "score", "tiebreak", "sha", "snapshot", "note"}. Dependencies cannot cross stages. Engine operations cannot be in the epilogue. The workflow result must be in the iteration stage.

In a playbook, the epilogue runs after the main graph completes or fails. It reads the reserved outcome input: {"exit", "tasks": {name: {"status", "note", "output", "files"}}}. Each entry is the same as the value that a join = "settled" consumer gets, with per_instance. The engine stages the declared files of each main-graph task under inputs/<name>/ (for a mapped node, under inputs/<node>[<key>]/, where %, /, \, NUL, and a leading . in the key are written %25, %2F, %5C, %00, and %2E, so llm-d/router stages under inputs/<node>[llm-d%2Frouter]/). It stages files from failed tasks and from passing tasks. files tells if the files of that task are present. A skipped, blocked, or transport-failed task stages no files.

Epilogue results are advisory. They cannot undo the keep of the candidate. The engine writes their rows to the session log and RESULTS.md (epilogue / epilogue-skip / epilogue-fail). The PR body gets an "Epilogue checks (advisory)" section, with failures marked FAILED.

report(name = "publish-report", destination = {"kind": "slack"}, template = "reports/slack.md.j2", result = roundup, required = True) is the publication epilogue that the engine owns. A destination is a key that the engine knows, not a URL or a secret name. slack is the only destination. The optional result selector puts only the declared JSON fields of that main-graph task into a Block Kit card that the engine builds. The card does not show prompts, stdout, workspaces, undeclared fields, raw Slack blocks, channels, or credentials. The default limit for the selected output is 16 KiB encoded. An operator can set a different limit, up to 64 KiB, with CRUCIBLE_REPORT_RESULT_MAX_BYTES. CRUCIBLE_REPORT_BODY_MAX_BYTES limits the rendered template body (default and maximum 3000, the Slack section limit). Data that is too large fails. The engine does not truncate it. For a required report, a render or delivery failure fails the workflow. An agent does not have to call a tool to send the report.

Worked example

examples/revise-loop is the smallest revise loop. An agent writes a draft probe. A command rejects the first draft, which the agent wrote without a verdict. The revision, which the agent wrote with the verdict, passes. just revise-loop-e2e runs it in real OpenShell sandboxes on local podman with a claude image that has no model. It checks the rounds, the continued session, and the commits.

examples/adversarial-review puts a review task between a code node and the gate below it. It has a single-reviewer shape and a two-reviewer panel shape. The panel runs isolated reviewers concurrently and joins them on a policy gate. The correctness reviewer blocks, and the copy-edit reviewer is advisory. It runs at no cost against a stand-in manifest, or against real models with the live manifest.

Plan execution states

Generated from crucible/src/plan/machine.rs by crucible plan states; scripts/state-docs.sh --check keeps it current. The executor walks both tables at every decision, so an edge missing here is a path it cannot take. The graph itself, what the tasks are and how they depend on each other, is described in Work graphs; this page is how the executor walks one.

One task

A task is pending until its dependencies settle, runs (retrying transport-class failures up to the configured count), and settles on one of the six statuses the session log reports. Everything that reaches a settled state without an attempt is a blocked task, and the edge names why.

One task's states

The plan

The plan dispatches ready tasks in topological order until every task has settled or a required task fails or a ceiling is reached. After a halt the remaining tasks settle as blocked; epilogue tasks still run after a required task fails so the failure is reported, and tasks that cannot spend still run after the cost ceiling so the run reports the work it paid for.

The plan's states

The sources are docs/img/plan-task-states.dot and docs/img/plan-states.dot (crucible plan states --format dot).

Why a task is blocked

The note on a blocked task's result is one of these.

NoteMeaning
required task <task> failedA required task failed and the plan short-circuited before this one ran.
budget ceiling reachedThe plan's spend reached its budget and this task, or this lane of a fan-out, can spend.
wall-clock ceiling reachedThe plan's wall-clock limit passed.
dependency did not passA dependency settled without passing and the task's join needs it to.
<the runner's reason>The runner could not stage the task's declared inputs.

How a plan ends

StateTokenMeaning
CompletedfinishedEvery task settled; the plan is valid when every required task passed.
Haltederror or budgetA required task failed (error), or a budget or wall-clock ceiling was reached (budget); the rest drained as blocked.
TruncatederrorA required task cannot run on this substrate; nothing was dispatched.

Example: build, run, measure, iterate

examples/optimize-loop is a playbook in which a sandboxed agent optimizes a program against real measurements on a cluster, and a verifier checks the result against evidence rather than the agent's word. It uses the two MCP servers a run can be granted with agent tokens (see Agent and people MCP architecture):

  • buildit builds the agent's workspace into an image in a builder pod and pushes it to the run's push repository by digest;
  • crucible-kueue submits each benchmark as a Kueue Job with fixed CPU and memory, waits on it, and reads its logs.

The agent measures a baseline, changes the slow function, rebuilds and reruns for up to rounds rounds, and runs two or three variants as parallel Jobs in at least one round. It reports each round's Job name, image digest and numbers.

How the result is checked

The loop pod, where the verifier runs, holds no kueue token and no rights in the workload namespace. A second agent task, collect, reads crucible-kueue for every Job this run created and writes the replies to a file; its prompt carries nothing the optimizer wrote. The verify evaluate task then checks the report against those replies, against the registry (read with the loop pod's push authfile), and against its own reference answer, computes the speedup itself, and passes only above min_speedup. The pack's README lists every check.

Where the numbers show up

verify prints its verdict as its result: pass, speedup (also its score), baseline and best (each a Job, its image and the ops/s the evidence gave), rounds (each round's Jobs with their measured ops/s and p50, or why one was rejected, and the round's best), and any failures. A failing verdict keeps everything it measured. The controller stores it as the task's output on the run's task results, so it outlives the loop pod:

  • crux graph <run> prints it under the task table, and crux graph <run> --json returns each attempt's whole output;
  • the run view's verify task panel shows it as the task's payload;
  • just optimize-loop and just optimize-loop-e2e print it when the run ends, pass or fail.

Launch it

just optimize-loop-test
just optimize-loop <kube context> <controller namespace>            # CPU, 3 rounds, 5x
just optimize-loop <kube context> <controller namespace> true 2 3   # GPU, 2 rounds, 3x

The controller's profile needs [buildit] with a push_repo, [secrets] push_authfile, and [kueue] with that repository among its pull secrets. The GPU variant also needs a LocalQueue with NVIDIA GPU flavors.

How the loop works

This diagram shows the autoresearch lane. A goal goes in, and an agent proposes a change. The engine applies the change to a reversible world. A frozen judge measures it one time. The loop keeps the change only if it is strictly better than the best result so far. Git stores the accepted states, and the operator can steer the run. Privileged operations run on the host in a broker. The agent can only send requests to the broker.

flowchart TD
    issue["GitHub issue / Jira ticket"] -->|scope --issue| goal["Run goal<br/>(frozen objective)"]
    goal --> wide["wide round (optional)<br/>N parallel propose → rank → winner seeds"]
    wide --> propose

    subgraph control["Control plane (operator, human-in-the-loop)"]
        direction LR
        budget["budget"]
        steer["steer"]
        stoppark["stop / park"]
        resume["resume"]
        escalate["escalate"]
    end

    subgraph loop["The keep / discard loop"]
        direction TB
        propose["propose<br/>agent backend: local · openshell · command"]
        apply["apply<br/>World: edit tree, or build + set image"]
        measure["measure<br/>Judge: frozen, scores once"]
        accept{"accept?<br/>strictly better?"}
        remember["remember<br/>git commit + session NDJSON"]
        restore["restore<br/>World rollback to last good"]

        propose --> apply --> measure --> accept
        accept -->|keep| remember
        accept -->|discard| restore
        remember -->|next iteration| propose
        restore --> propose
    end

    control -. "steer / stop / resume / budget" .-> loop

    subgraph broker["MCP broker: the host keeps the credentials, the agent sends requests"]
        direction TB
        prov["provisioning<br/>GPU / Kueue, capture RBAC"]
        jira["issue-tracker grounding"]
        prof["profiler<br/>pprof / GPU traces"]
        forge["forge build + deploy<br/>buildah / native-OCI derive-layer, digest-pinned"]
        draft["draft PR per fork"]
    end

    propose -. "MCP ask" .-> broker
    forge -. "image + set image" .-> apply

    remember -->|publish-on-keep| s3[("S3 run records<br/>session.jsonl · summary.json · diffs")]
    remember --> draft
    draft -->|review comments re-steer| steer
    remember -->|evidence upload, pod token| ctl["control plane<br/>(crucible-controller)"]

Loop control states is a generated diagram of the controls around the loop: the gates between iterations and the ways a run ends.

The stages

StageWhat happensDeeper
GoalA GitHub issue or Jira ticket becomes a frozen run objective. The objective does not change after the run starts.ADR-0001
wide roundOptional. --wide N runs N independent propose turns in parallel, one for each [search].approaches entry. The gate ranks them, and the winner is the start point of the deep loop. With 0 (default), the run starts at propose.ADR-0010
proposeThe agent reads the history and edits the world toward the goal. A backend (local, openshell, or command) supplies the proposal policy. The engine does not.The loop in one read
applyMakes the candidate live. For a code repository, the edits are the apply step. For a deploy domain, apply builds and sets the image.ADR-0005, ADR-0011
measureThe frozen judge scores the candidate one time. The agent gets a World but not the Judge, so it cannot change the test that grades it.ADR-0001
accept?Keeps the candidate only if it is valid and its score is strictly better than the best score in the direction. Otherwise, restores the last good state.Contract
as a planEvery iteration runs as a work-graph plan (propose -> apply -> measure -> decide) through the shared executor. An authored workflow replaces the default graph.Work graphs
rememberKept states are Git commits. Each step is an NDJSON session event. Git is the durable store. Each run writes exactly one shutdown event as its last line, so the viewer and any published record can tell a clean end from a pod that stopped during the run.ADR-0004
brokerProvisioning, issue-tracker grounding, the profiler, build and deploy, and draft PRs run on the host. The agent sends requests over MCP. The privileges do not go into its sandbox.ADR-0002, ADR-0006
publishOn keep, the engine sends the run record to S3 and opens a draft PR for each fork. Authorized review comments steer the run again. A pod that the control plane dispatches uploads its evidence to the controller with its pod token, and the controller shows leaderboards and run pages. The engine binary does not link the controller.Contract

Glossary

Some words have a specific meaning in the loop. World and Judge are the most important, because they define the trust boundary.

TermPlain EnglishWhat it actually is
WorldThe workspace that the agent can change.The state that the engine can change and restore: the code tree, and for a deploy domain also the live deployment. GitWorld / CommandWorld. The agent changes only the World.
JudgeThe measurement script and the pass threshold.The frozen objective. It runs measure, gets {valid, score, solved}, and decides keep or discard in the direction. CommandJudge. The agent cannot change it.
CandidateThe change that the iteration grades.One proposed change of the World. The engine applies it and measures it one time.
measure / gateThe scoring step.The command that prints {valid, score, solved?}. A nonzero exit status makes the result invalid. This is the scoring step of the Judge.
keep / discardAccept or reject.Keep only if the result is valid and the score is strictly better than the best score in the direction. Otherwise, restore the last good state.
directionThe meaning of better.lower (for example, p99 latency) or higher (for example, success rate).
Agent / backendThe source of the change.The proposal policy that edits the World: local (the harness on the host), openshell (sandboxed), or command. [agent].backend sets it.
snapshot / restoreSave point and rollback.An opaque token for the current World state, and the rollback to it. The default is a Git ref and git reset --hard.
DomainA problem packaged for the loop.A crucible.toml manifest and some executables (measure, and optionally apply, snapshot, restore). Usually no Rust.
Composite domainSeveral domains that run as one.A list of component domains in one run (for example, a router and the model server behind it), with one combined gate.
DeploymentThe live test environment.The cluster topology that a deploy domain creates for measurement.
BrokerThe host-side service that keeps credentials.The MCP service that holds all privileges (provisioning, issue-tracker access, profiler, build and deploy, draft PRs). The agent sends a request, and the broker does the operation.
forgeThe builder.Image build and deploy in the engine: buildah for source builds, and native-OCI derive_layer for a base image with an edited file.
publish-on-keepPublish a kept result.For a kept candidate, push the run record to S3 and open or update a draft PR for each fork.

The loop in one read describes the same model in text.

The autoresearch loop, in one read

Autoresearch is the scored lane of Crucible. It is a keep/discard loop with a goal and a gate, in which an agent improves a codebase against a frozen objective. The loop does not depend on a domain. A domain is a problem packaged for the loop. For a graph that runs one time and ships, see Your first playbook.

This page describes the system in general concepts. Then it shows how the examples/counter domain maps to those concepts. ADR-0001 gives the reason that the judge is frozen.

Scope. The core is the engine, the World/Judge boundary, CommandWorld and CommandJudge, and the crucible.toml manifest. A composite manifest (ADR-0009) runs the engine across more than one component. Around the loop there are: provisioning and issue-tracker grounding through an MCP broker (ADR-0002), a profiler over MCP (ADR-0006), build and deploy in the engine (ADR-0005) with rendered deployment manifests (ADR-0011), and publish-on-keep with a draft PR for each fork. Authorized review comments on the PR steer the run again.

Summary

The loop is an optimization loop with these properties:

  • The proposal step is an LLM agent.
  • The objective is a frozen, expensive black-box evaluation.
  • The world is state that the engine can change and restore.
  • The loop has durable memory and human-in-the-loop control.

The loop

flowchart LR
    controls["budget / steer / stop / escalate"] -.-> propose["propose<br/>(agent)"]
    propose --> apply["apply<br/>(World)"]
    apply --> measure["measure<br/>(Judge)"]
    measure --> accept{"accept?<br/>keep or discard"}
    accept -->|"keep"| remember["remember<br/>(Git + session log)"]
    accept -->|"discard"| restore["restore<br/>(World)"]
    remember --> next["next iteration"]
    restore --> next
    next --> propose
  • propose: the agent edits the world toward the goal. A backend supplies the proposal policy: local (the harness on the host), openshell (sandboxed), or command. [agent].backend selects it.
  • apply: makes the candidate live. For a code repository, the edits are the apply step.
  • measure: the frozen Judge scores the candidate.
  • accept?: keeps the candidate if it is strictly better. Otherwise, restores the last good state.
  • remember: kept states are Git commits. Each step is an NDJSON event.

By default, the loop has one candidate in each iteration. A wide round (ADR-0010) can first run N independent propose turns in parallel, each biased to a different [search].approaches entry. The same Judge ranks them, and the winner is the start point of the loop. With --wide 0 (the default), there is no wide round. The wide round is a step before the loop and does not change the engine.

The contract

Crucible defines a small contract. A repository implements it in any language. A domain is a manifest and some executables. It is not Rust code. Each command runs in the workspace:

CommandRequiredContract
measureyesThe last stdout line is { "valid": bool, "score": number, "solved"?: bool, "note"?, "detail"? }. A nonzero exit status makes the result invalid. This is the judge. It gets CRUCIBLE_BASELINE_* and CRUCIBLE_BEST_SCORE in its environment.
applynoApplies the candidate. Omit it for code repositories, where the agent edits files. Deploy domains build, push, and set the image.
snapshotnoPrints an opaque token for the current state. Default: a Git commit ref.
restore <token>noRolls back to a token. Default: git reset --hard and clean.
setupnoPrepares the workspace. Default: Git clone and checkout.

What of the engine's environment measure, apply, snapshot, and restore get depends on the pack's edition, a top-level key of crucible.toml:

edition = 2

A manifest without edition is edition 1. Its commands inherit the engine's whole environment, credentials included, and crucible check and every run warn that the pack should move to edition 2. The capability disclosure says so per table: host-env [judge] commands inherit the engine's whole environment.

Under edition 2 the commands get a minimal environment, not the engine's: PATH, HOME, LANG, TMPDIR, the run's identity (CRUCIBLE_RUN_NAME, CRUCIBLE_RUN_DISPLAY_NAME, and CRUCIBLE_UI_BASE_URL, when the platform sets them), the variables the engine sets for that command (CRUCIBLE_BASELINE_*, CRUCIBLE_BEST_SCORE, CRUCIBLE_TOKEN), and the variables its table declares. The pod token and the controller URL it authenticates against are not part of the base. The agent can edit files a command runs, so nothing else of the engine's environment reaches it: no kubeconfig, registry authfile, broker token, or provider key unless you name it.

edition = 2

[judge]
measure_cmd = "./measure.sh"
direction = "lower"
env = ["KUBECONFIG", "EPP_ENDPOINT?"]        # measure_cmd only

[world]
apply_cmd = "./deploy.sh"
env = ["KUBECONFIG", "REGISTRY_AUTH_FILE"]   # apply, snapshot, and restore

[workflow].env does the same for a workflow's command and evaluate tasks. An env list in an edition-1 manifest fails the parse, and so does an edition this engine does not know. A name is a letter or underscore followed by letters, digits, and underscores. A required variable that the engine's environment does not set refuses the run before any of these commands runs, naming every missing one; crucible check reports it as a finding. A name with a trailing ? is optional: the command gets it when the engine's environment sets it and runs without it otherwise, so a script can keep a shell default (${EPP_ENDPOINT:-127.0.0.1:8000}) that a deployment overrides. Only the trailing ? is allowed (? alone, A??, and A?B fail the parse), and a name is either required or optional, not both. crucible check lists each table's declared variables in the capability disclosure as host-env, the optional ones marked (optional). setup runs with the engine's environment in every edition.

The accept policy is the same for all domains. The engine keeps a candidate if it is valid and its score is better than the best score in the direction. The run is solved only if measure reports it. A domain does not supply code for the keep logic.

Carrying pipeline artifacts across a discard

A discard resets the workspace and removes untracked files. Any files that an iteration generated for its candidate (code traces, a generated port tree) are gone in the next turn, and the agent must generate them again. [workspace] carry_forward names paths, relative to the workspace, that the discard keeps:

[workspace]
carry_forward = ["codegen-out/"]

The engine writes each entry to .git/info/exclude, and the clean step of the discard keeps it. Carried content does not go into a candidate diff, a snapshot commit, or a tree hash. If a turn only generates the carried content again, the engine sees no candidate change.

Limits:

  • A path that the repository tracks is not protected. Git excludes apply only to untracked files, and git reset --hard in the discard reverts tracked content. Carry only pipeline output that the repository does not track.
  • A path can be absent when the run starts. The exclude applies when the path appears.
  • Nested paths (target/codegen-out) work. The clean step goes into the parent directory and does not delete it. Entries must be plain relative paths, without ., .., or a leading /.
  • Wide-tournament rounds run in new worktrees without the carried directories, so the setting does not apply to them.
  • Composite manifests reject the key. Carry-forward for each component is not supported.

If you omit the key, each iteration starts from a clean workspace. Use this default for a campaign whose method needs a clean start.

Declared artifacts: carried AND published

[[workspace.artifact]] is carry_forward with publishing. Use it for generated output that a reviewer must see, but that must not be in the candidate diff:

[[workspace.artifact]]
path   = "codegen-out/runtime"   # carried, same rules as carry_forward
embed  = "summary.txt"           # newest match → PR body (collapsed, 16 KiB cap) + record
upload = "summary.pptx"          # newest match → record only (binaries a PR can't inline)
title  = "Runtime investigation" # PR-body section heading (default: the path)

The newest match is used. If versioned pipeline directories (V1 … V7) each have a summary with the same name, the summary of the final iteration is published. The record destination accepts s3://bucket[/prefix] or file:///abs/path. The file form writes the same layout to a mounted PVC, for clusters that cannot reach S3. If two or more iterations have a measurement, the PR body also shows the score of each iteration as a mermaid xychart-beta chart.

Languages

The contract is JSON and exit codes, so you can write each command in any language:

  • Small glue commands (apply, operations, steer, stop): we recommend nushell. These commands run kubectl or Prometheus, parse the output, and compute a value. Nushell works with structured data (... | from json | ... in, a record | to json out). Nushell is not a POSIX shell, so pin nu in the sandbox image.
  • A large measure with real math (percentiles, replay): use a compiled binary (Rust or Go).
  • Bash, Python, and other languages are also valid. Only the JSON and exit status format is fixed.

measure_cmd is any executable that prints one JSON object on stdout ({"valid": bool, "score": number, "note": string}). A plain POSIX shell script satisfies the full contract:

#!/bin/sh
p99=$(make bench | jq .p99_ms)
printf '{"valid": true, "score": %s, "note": "p99 %sms"}\n' "$p99" "$p99"

The engine reads only the JSON verdict, so all languages work the same way.

What the engine supplies

ConceptWhat it doesWhere
EngineThe loop, budget, keep/discard, baseline.crucible/src/runloop/driver.rs (run_loop)
WorldState that the engine can change and restore. Opaque Snapshot.crucible::World → GitWorld / CommandWorld
JudgeThe frozen objective: measure and decide.crucible::Judge → CommandJudge
AgentProposal policy and transport (local or sandbox).agent::AgentSource
ReportersConsole, NDJSON, and session-log output.report::reporter::Reporter, report::console, report::stream
Session logVersioned NDJSON event stream. It is the source of truth.crucible/src/report/session.rs
MemoryKept Git commits and RESULTS.md.crucible-vcs/src/vcs.rs, write_results
Run controlsSteer, stop or park, resume, escalate.STEER.md / state/control.json / --resume / ESCALATION.json
DistressThe agent pages the operator. severity=error suspends the run and keeps the pod.crucible-broker::distress, crucible/src/control/distress.rs
Durable run state[cluster] state_pvc mounts a claim over the state/ directory of the domain. A replacement pod resumes the run (loop or playbook) and does not start it again. A named (shared) claim uses a state/<run> subPath for each run and needs RWX. A [cluster.state_pvc] table creates a dedicated <run>-state claim, mounted at its root.deploy/profile.rs, deploy/render/kube.rs
ProvisioningMCP broker. The agent sends requests, and the host keeps the credentials (GPU capture, issue-tracker grounding, draft PRs).crucible-broker (ADR-0002)
ProfilerProfiling over MCP: pprof for a Go service, GPU traces for a model server.crucible-broker::profile (ADR-0006)
Build and deployBuild in the engine. crucible deploy render writes digest-pinned loop and deployment manifests.forge, crucible/src/deploy/ (ADR-0005 / 0011)
PublishPublish-on-keep to S3 and a draft PR for each fork. Authorized review comments steer the run again.crucible/src/runloop/publish.rs, crucible-broker::draft_pr
Search (wide round)Optional N parallel propose turns before the deep loop. The same Judge ranks them (ADR-0010).crucible/src/runloop/graph.rs (wide_template)
Self-testBefore a run uses the Judge, crucible check proves that the Judge can tell a known-good configuration from a known-bad one.[judge.selftest], crucible/src/cli/selftest.rs
Setupcrucible init creates a manifest and a measure script in an existing repository. crucible check validates it without an agent turn. crucible scope reads a goal and freezes a SCOPE.md (ADR-0012).crucible/src/cli/init.rs, crucible/src/cli/check.rs, crucible-contract/src/scope.rs
PreflightRuns the rung ladder of the domain against the unmodified tree before iteration 1. A failure stops the run. The optional baseline rung sets segment.baseline_score.[preflight], crucible/src/runloop/preflight.rs

The control plane (crucible-controller) is optional. It finds candidate issues, scopes them into packs, launches runs, and shows run records as leaderboards. The controller links the engine. The engine binary does not link the controller, and everything on this page works without a controller.

Trust boundary (ADR-0001): the engine gives the agent a World but not a Judge. The objective is frozen, and the agent cannot reach it, so the agent cannot change the test that grades it. The same boundary applies to provisioning. The agent can only send requests to the broker over MCP. The privileges (GPU and Kueue, capture RBAC, the forge token, the issue-tracker credential) stay on the host and do not go into the sandbox of the agent.

Two ways to build a candidate. Neither runs in the agent. If a candidate is a source build (the Dockerfile of a compiled service), forge runs buildah. If a candidate is the base image with one or two edited files (an interpreted-source overlay, for example one Python file), forge::oci::derive_layer adds one tar+gzip layer to the base and pushes an immutable, digest-pinned image. It needs no container runtime (oci-client, sha2, tar, flate2). If the registries are the same, base layers move by a server-side blob mount. If not, they move by a low-memory pull and push stream. The deployment then uses set image with a digest, so a snapshot or restore is only a ref. forge-derive-layer is the CLI that validates the round trip on a cluster.

[preflight]: prove the environment before spending an iteration

A rung that grades a candidate can fail for reasons that are not related to the candidate: a missing dev header, a pip install that hides the built wheel, a cache directory that is not writable. If the run finds these problems in iteration 1, it loses a full agent turn. [preflight] runs the same ladder against the unmodified tree first, with no agent cost:

[preflight]
commands = [
  "python3 tools/gate.py --only-rung 1 --mode {mode}",
  "python3 tools/gate.py --only-rung 2 --digest {digest} --limit 1",
]
baseline = "python3 tools/gate.py --only-rung 3 --digest {digest}"
  • Each command runs with sh -c in the workspace. Its last non-empty stdout line must be a JSON object. The rung passes if the command exits with status 0 and pass is absent or true. The recognized keys are digest, score, tiebreak, note, and logs.
  • {mode} runs the command once for each build mode in [measure.build].mutable_kwargs.mode, in declared order. A {mode} command without that list is a manifest load error. Without this rule, a preflight could pass in derive mode while full mode is broken.
  • {digest} inserts the most recent digest that an earlier rung emitted, so the build rung gives its artifact to the correctness rung. If no rung produced a digest yet, the command fails.
  • Any failure is an environment verdict. The run does not start. It logs the failed command, its note, and the end of its stderr as a preflight-failed row. The run uses no iteration.
  • baseline is optional. Its score and tiebreak become segment.baseline_score. A skip_baseline (codegen) domain then decides iteration 1 against a real number, not against the worst-score sentinel of the direction. Without baseline, the sentinel is used.

The three extension points

  • New domain: usually a manifest that points CommandWorld and CommandJudge at a repository, and a measure command. No Rust. Write a custom World or Judge only for in-process measurement or a world that is not files.
  • Composite domain: combine N existing component domains into one run. It has a top-level [composite] table that references the component domain directories (used without change), a CompositeWorld (a tuple of Git overlays, one for each component, and the live deployment), and one combined gate over the full stack. The engine does not change. It runs a list of workspaces (ADR-0009).
  • New frontend: implement Reporter.
  • New agent transport: add an AgentSource.

The canonical example: the counter, mapped to the contract

examples/counter/ is the full system, with each large part replaced by the smallest part that satisfies the contract. Each of its five small files has a contract role:

FileLangContract role
crucible.tomltomlmanifest ([repo] path = ".", direction = "higher", no [world] → GitWorld)
measure.nunumeasure (score = the integer in value.txt; solved at ≥ 5)
bump.nunupropose with the command backend (deterministic: value.txt += 1, no LLM, no cost)
setup_cmd (inline in the manifest)shsetup (seed a fresh git workspace with only the runtime files)
method.mdmdmethod prompt (used only if you set backend = "local")

There is no [world] block, so GitWorld supplies the restore: kept iterations are commits, and discards use git reset --hard. The backend is command, so the whole loop runs in milliseconds and costs nothing. It is still a real end-to-end run (manifest → setup → propose → measure → keep/discard → Git history).

A production domain fills the same roles with larger parts. A serving-stack domain has two reversible parts: the code tree and a live deployment. It adds snapshot_cmd and restore_cmd that capture the image and configuration together with the Git state. Its measure is a compiled benchmark binary. Its propose backend is local or openshell. Its glue commands (apply pipelines, deployment inspection, steer and stop) are on PATH as bare names, so the contract names are the same for every implementation. A normal repository needs none of this. It needs one measure command and the Git default.

Add a new domain

  1. crucible init creates a starter crucible.toml and a measure script in the repository. You can also write the manifest by hand:
    [repo]      url|path, ref
    [workspace] setup_cmd          # optional; default: git clone + checkout
                carry_forward      # optional; untracked derived paths a discard keeps
                [[workspace.artifact]] path, embed?, title?   # carried + published to PR/S3
    [agent]     model, method_prompt, goal_file|goal, toolbox_dir, env
    [judge]     measure_cmd, direction = "lower"|"higher"
    [world]     apply_cmd?, snapshot_cmd?, restore_cmd?   # omitted → git
    
  2. Write a measure command (any language) that prints {valid, score, solved?}.
  3. crucible check --manifest crucible.toml validates the manifest without an agent turn. It checks that every referenced file resolves, and it runs measure_cmd one time to check the output format.
  4. Run crucible --manifest crucible.toml. The engine supplies the loop, the budget, all frontends, steer, stop, and resume, the session log, and escalation.

Any other repository can run this way with no new Rust.

The task lane: no [judge]

Some work has no objective to score, for example "consolidate the open dependabot PRs" or "fix flaky tests every night". If you omit [judge], the run is a task. The loop runs as usual (sandbox, broker, session log, publish, resume), with the built-in TaskJudge, which keeps everything. There is no baseline measure and no score. The engine keeps and snapshots every completed turn. The run exits with status 0 when no iterations remain (ADR-0021).

[repo]
url = "https://github.com/you/repo"
[agent]
backend = "openshell"
goal = "Consolidate the open dependabot PRs into one branch; run the tests."
[publish]
pr_repo = "you/repo-fork"   # the deliverable: kept commits as one draft PR

On the wire, the run has the normal format, with gate: "task" as the discriminator: an iteration-0 baseline-skipped row, then keep rows with score: null, and Summary.best_score null. crucible check does not probe the measure. It requires a goal and prints a "task mode" notice. Notes:

  • Exit status 0 means that the run completed. It does not mean that the task succeeded. Examine the rows or the PR.
  • solved does not occur. Only the iteration limit ends the run (--no-early-stop has no effect).
  • The trust boundary does not change. The agent has no credentials. The output is a draft PR. Privileged write actions (merge, close issues) stay in broker tools.
  • Composites still need [judge]. [search], [workflow], and [preflight] also need it, and a task manifest rejects them at load. The scope pipeline rejects proposed packs without a judge, and a scored judge cannot use objective = "task".

examples/task/ is the runnable reference (deterministic command backend, no LLM). Tasks: the no-judge lane is the full guide.

Distress: the agent can page you

The broker has a distress tool. The agent uses it to report that a run cannot succeed. The required severity sets the result:

severitySlackRun
infopostedcontinues. The note goes on the next decided row in RESULTS.md.
warnpostedcontinues. The note goes to the same place.
errorpostedsuspends. The current turn finishes and the engine decides it. Then the loop parks. The pod stays alive and keeps its state.

Use error for a run that cannot succeed without an operator: the same environment failure in many iterations, a missing capability, or a goal that is not possible as written. Do not use it for a task that is difficult but possible. The tool replies {"status":"suspending"}, and the agent finishes its turn. The suspend occurs at the start of the next iteration, after the engine records that turn, so no data is lost. More error calls during the suspend have no effect. If the engine cannot write the marker (a full or read-only volume), the tool returns an error, not suspending, and posts nothing. The run is not suspended, so the agent and the page must not report that it is.

The suspend is a pending approval. The loop opens the same approval_wait bracket that provisioning uses, so suspended wall-clock time goes to parked_total and does not count toward --max-time. The distressed iteration still counts, because its turn ran. The suspended time counts against no limit. --max-park limits the wait.

The signal is one file on the forge-storage volume of the loop pod: /var/lib/forge/distress. To resume the run, delete the file:

oc exec <pod> -n <ns> -- rm /var/lib/forge/distress   # resume in place

If the fix must be in place at pod start (image pins, the pack, resources), replace the pod. The marker is on an emptyDir, so the new pod starts without it. A resume that finds the open distress bracket does not park again. Info and warn notes go to /var/lib/forge/distress-notes.jsonl, and the engine adds them to the next row.

The render sets these variables on the loop pod. The broker inherits them, so the page can name the run: CRUCIBLE_RUN_NAME, CRUCIBLE_ITERATIONS, CRUCIBLE_LOOP_IMAGE, and the downward-API CRUCIBLE_POD_NAME and CRUCIBLE_POD_NAMESPACE. A pod that the controller dispatches also has CRUCIBLE_RUN_DISPLAY_NAME, the two-word name of the run (benevolent-monkey). The page and report cards show this name instead of the run id. Links still use the id. Slack supports only webhooks. Set SLACK_WEBHOOK_URL to an incoming webhook through the [[secret_env]] of the profile. Set DATADOG_BASE_URL if your Datadog site is not app.datadoghq.com. Without a webhook, the suspend still occurs. The engine sends the page one time and does not retry.

crucible flow: explain a finished run

crucible flow --session state/session.jsonl --out flow.html converts a finished run into a small flow-model IR and renders it. The --out extension selects the format: .json (the IR), .dot, .mmd, or .html. The HTML format is a self-contained page with a header, a swimlane of iterations (agent turn and check ladder in each column, scores against the baseline), detail for each iteration, and the draft-PR footer. The optional --spans <dd-export.json> (Datadog APM) adds real wall-clock time: columns in proportion to time, check durations, and the tool-call timeline of the agent.

Concept to code

  • loop, budget, keep/discard: crucible/src/runloop/driver.rs, crucible/src/runloop/graph.rs
  • the contract traits: crucible/src/crucible.rs
  • World and Judge implementations: crucible/src/command_world.rs (GitWorld, CommandWorld, CompositeWorld), crucible/src/command_judge.rs (CommandJudge)
  • composite domains: crucible/src/manifest/mod.rs (CompositeManifest), crucible/src/cli/scored.rs (run_composite)
  • broker (provisioning, issue tracker, profiler, draft PR): crucible-broker/
  • distress (suspend from the agent, and the Slack page): crucible-broker/src/distress.rs, crucible/src/control/distress.rs
  • build and rendered deploy in the engine: forge/ (build, native-OCI derive, kube-rs), crucible/src/deploy/
  • agent transport: crucible/src/agent/
  • frontends: crucible/src/report/{console,stream}.rs
  • session log wire format: crucible/src/report/session.rs
  • the example domain: examples/counter/ (manifest, measure.nu, bump.nu)

Getting started: zero to a running loop

This page shows how to start an autoresearch loop from nothing. For a graph that runs one time, start with Your first playbook. This page has two parts:

  1. Run a loop locally. The example domain needs no cluster and no LLM, and it runs in milliseconds.
  2. Onboard your own domain. You write two things: the manifest and the measure command.

How the loop works (the diagram and glossary) and The autoresearch loop, in one read explain the design. The manifest and command formats on this page expand on the contract, which is the normative reference. If this page and the contract are different, the contract is correct.

Before you start

Tools. To build, you need Rust and Cargo. Part 1 also needs nu (nushell, the glue language). Development and deployment use more tools: Go, kubectl or oc, helm and helmfile, just, podman and buildah, gitleaks, and the claude CLI.

Credentials. Part 2 with a real agent backend needs Claude access for the claude CLI. The command backend and crucible init, check, and scope need no credentials.

Build the binary. Clone the repository (part 1 uses the examples/counter files) and build from source:

git clone https://github.com/neuralmagic/crucible.git
cd crucible
cargo build --release -p crucible --features autoresearch
install -m 755 target/release/crucible ~/.local/bin/   # or anywhere on PATH

You can also download a prebuilt binary from the releases page, if there is one for your platform. The binary alone is sufficient to onboard your own repository with the crucible init → crucible check → crucible scope steps of part 2.

Before part 2, note this convention: a manifest refers to the tools of a pack by bare name. The counter example does not use this convention, because bump.nu and measure.nu are in the pack directory and the manifest calls them by relative path. A pack that ships tool binaries must put them on $PATH before the loop runs.


Part 1: run a loop locally

examples/counter is the fastest way to see the full engine. The repository under test is a Git tree with one integer in value.txt. The score is that integer. A deterministic command backend (bump.nu) replaces the LLM, so the run costs nothing. It uses the real generic path (manifest → setup → propose → measure → keep/discard → Git history) without a cluster or a model.

crucible --manifest examples/counter/crucible.toml --iterations 6
# or, from a source checkout: cargo run -p crucible --features autoresearch -- --manifest examples/counter/crucible.toml --iterations 6

The manifest (examples/counter/crucible.toml) defines the full run:

[repo]
path = "."

[workspace]
dir = "workspace"
setup_cmd = "mkdir -p workspace && cp value.txt measure.nu bump.nu workspace/ && git -C workspace init -q && git -C workspace add -A && git -C workspace -c user.email=crucible@local -c user.name=crucible -c commit.gpgsign=false commit -qm baseline"

[agent]
backend   = "command"
agent_cmd = "./bump.nu"   # deterministic proposer: value.txt += 1
goal      = "Raise the integer in value.txt as high as you can (target: 5)."

[judge]
measure_cmd = "./measure.nu"
direction   = "higher"
objective   = "value"

# No [world] block -> GitWorld: kept iterations are commits, discards reset --hard + clean.

Each iteration: the engine runs agent_cmd to change the workspace and runs measure_cmd to score it. It keeps the change as a Git commit only if the score is strictly better in the direction. If not, it rolls the workspace back. The run writes:

  • examples/counter/state/session.jsonl: the append-only NDJSON event log. It is the source of truth for the run.
  • The Git history of the workspace. Each kept iteration is a commit.

To use the engine on real work, replace the deterministic proposer with a real agent and point measure at a real objective.


Part 2: onboard your own domain

A domain is a manifest and some executables. It is not Rust. A new domain runs without engine changes. You write two things: a crucible.toml and a measure command.

crucible init creates both in the current directory: a minimal manifest and a measure script that always reports the same score. You then edit the two generated files. After you point [judge].measure_cmd at the real gate, run crucible check --manifest crucible.toml. It validates the configuration before you spend an agent turn:

  • every referenced file resolves;
  • measure_cmd runs one time and prints the contract format;
  • it warns if the gate is unprotected in the workspace that the agent edits.

crucible scope --pack <dir> runs one pipeline (ADR-0012): it resolves the goal, runs check, renders WORKFLOW.png, and freezes SCOPE.md with a RunIdentity digest. Add --issue owner/repo#N to take the goal from a GitHub issue. It fetches the issue through the GitHub API and uses GITHUB_TOKEN or GH_TOKEN.

The manifest

The contract gives the full schema and the meaning of each field. The parser rejects unknown keys, so a typo causes an error.

SectionPurposeKey fields
[repo]The code under test.url or path, ref
[workspace]The checkout location and fixtures.dir, setup_cmd, [[workspace.inject]] (src/dst/frozen)
[agent]The proposer and its settings.model, backend, method_prompt, goal/goal_file, toolbox_dir, [agent.env], [agent.openshell], [agent.broker]
[judge]The frozen objective.measure_cmd, direction (lower/higher), [judge.selftest] (good_cmd/bad_cmd)
[world]Restore of state outside Git.apply_cmd, snapshot_cmd, restore_cmd (omit all three for GitWorld)
[deploy]Image rebuild targets (deploy domains).[deploy.buildah] registry/dockerfile, [deploy.env]
[search]Wide round before the deep loop.wide, approaches (required if wide > 0), policy_k
[composite]Combine N domains into one run.[composite].name, [[component]] domain/pr_repo

The most important rule: if you omit [world], you get GitWorld, and Git is the only state that the engine rolls back. If you set any of the three *_cmd keys, you get CommandWorld: Git and your snapshot and restore commands, for example for a live cluster.

The measure contract

measure_cmd is the judge. The engine parses the last stdout line that starts with { as JSON:

{ "valid": true, "score": 234.1, "solved": false, "note": "p99 234.1ms" }
  • valid (required bool), score (number), solved (bool, default false), note and detail (optional).
  • A nonzero exit status sets valid=false, whatever the command printed.
  • The engine sets CRUCIBLE_BASELINE_SCORE, CRUCIBLE_BASELINE_TOTAL, and CRUCIBLE_BEST_SCORE, so your gate can compare.
  • Keep rule: the engine keeps a candidate only if it is valid and its score is strictly better than the best in the direction. If solved is true, the engine keeps the candidate and ends the run. Use solved for a bug-fix gate.

measure_cmd is any executable that prints one JSON object. A plain sh script is a complete judge:

#!/bin/sh
p99=$(make bench | jq .p99_ms)
printf '{"valid": true, "score": %s, "note": "p99 %sms"}\n' "$p99" "$p99"

You can use any language. The engine reads only stdout.

Backends

[agent].backend selects the proposer:

  • local (default): runs the selected harness (default claude) on this machine in the workspace. Needs the harness CLI and its credentials.
  • openshell: runs the turn in a sandboxed pod (Landlock and deny-by-default egress). Needs sandbox_image and an egress allowlist in [agent.openshell].endpoints. Use this backend in a cluster.
  • command: runs a fixed sh -c command as the proposal. It is deterministic, uses no LLM, and costs nothing. The counter example in part 1 uses it.

Composite domains

A manifest with a top-level [composite] table combines two or more existing component domains, without changes to them, into one run with one combined gate. For example, a server domain and a router domain each keep their own workspace and repository. One measure_cmd scores the combined system, so the gate judges a change to either component by the behavior of the pair. The engine does not change. It runs a list of workspaces. In addition to the manifest, a composite needs a combined gate that tests both components, and deploy targets for each component if the domains rebuild images.


Where to go next

Tasks: the no-judge lane

The autoresearch loop needs an objective, but some jobs have no objective. The task lane runs the same loop (sandbox, broker, session log, publish, resume) for work without a score: consolidate the open dependabot PRs, fix flaky tests every night, regenerate docs, triage an issue backlog. You need one manifest. You do not need Rust or a gate script.

To use the task lane, omit [judge]. No other setting is necessary (ADR-0021).

The minimal manifest

[repo]
url = "https://github.com/you/repo"

[agent]
backend = "local"        # or "openshell" for the sandboxed path
model   = "claude-opus-4-6"
goal    = """
Consolidate the open dependabot PRs into a single branch.
Resolve conflicts, run the test suite, and write a summary to RESULTS.md.
"""

[publish]
pr_repo = "you/repo-fork"   # kept commits ship as one draft PR

Run it:

crucible check --manifest task.toml     # validates; prints the task-mode notice
crucible --manifest task.toml --iterations 3

Each iteration is one agent turn. The engine keeps every completed turn and commits it to Git. There is no baseline, no score, and no discard. The run exits with status 0 when no iterations remain.

What the engine supplies

  • History: each turn is a commit. An interrupted run continues from the session log with --resume. The engine rolls back an incomplete turn to the last kept state.
  • The deliverable: the engine pushes kept commits as a draft PR ([publish].pr_repo). If a results bucket is configured, it also records the run log and the diff of each turn to S3.
  • The trust model: with backend = "openshell", the agent runs under Landlock with a deny-by-default egress allowlist, and it has no credentials. All privileged operations go through the broker tools, not the agent environment. You merge the PR.
  • Forensics: state/session.jsonl is the source of truth. crucible flow --session state/session.jsonl --out flow.html renders a self-contained page that shows what the agent did in each turn.
  • Steering: STEER.md, the control bridge, distress paging, and the limits (--max-cost, --max-time) work as in scored runs.

Semantics to know

QuestionAnswer
How does the run end?At the iteration limit, or at --max-cost, --max-time, or stop. solved does not occur. --no-early-stop has no effect.
What does exit status 0 mean?The run completed. It does not mean that the task succeeded. Read the rows or the PR.
What is on the wire?Start.gate == "task" is the discriminator. There is an iteration-0 baseline-skipped row, then keep rows with score: null.
Can a composite be a task?No. A composite combines scored components, so it needs [judge].
Which sections are not allowed?[search], [workflow], and [preflight]. All three need scores, so a task manifest rejects them at load. objective = "task" is reserved for scored judges.
Scheduled runs?The controller schedules only playbooks. For a task run, render the pod with crucible deploy render and start it from a scheduler, for example a Kubernetes CronJob.

The runnable reference

examples/task/ is the reference domain. The deterministic command backend replaces the LLM, so the full path (manifest → turns → keeps → session log → resume) runs in milliseconds:

crucible --manifest examples/task/crucible.toml --iterations 3

Use its manifest as the template for tools that generate task manifests.

Loop control states

Generated from crucible/src/runloop/machine.rs by crucible loop-states; scripts/state-docs.sh --check keeps it current. The driver advances through this table at every gate, so an edge missing here is a transition the loop cannot take.

Each Turn is one iteration's work graph (propose → apply → measure → decide), rendered in Work graphs. Everything else is the control shell around it: the gates at the Head, the parks, and how a run ends. Dashed states are idle; the colored edges are the ways out.

The loop's control states

The source is docs/img/loop-states.dot (crucible loop-states --format dot).

How a run ends

The edge label after the arrow is the shutdown token on the session log.

TokenMeaning
finishedall iterations completed
solveda kept candidate satisfied the win condition
budgeta cost or time cap was reached
stoppedstop signal received
escalatedthe agent declared the harness inadequate — halted for human review
stalledthe run stalled on consecutive transport failures — no turn could start

An error inside the loop reports error and takes none of these edges.

Images for a new domain

A Crucible run uses two pods. A domain that needs a toolchain must install it in both:

PodWhat runs thereBase imageDerive with
loop podthe engine, setup_cmd, measure_cmd, [judge.selftest], git memoryghcr.io/neuralmagic/crucible (docker/Containerfile.runtime)Containerfile.runtime-<domain>
agent sandboxthe agent turn (claude/codex/opencode/pi and the commands that the agent runs)quay.io/aipcc/agentic-ci/claude-sandboxContainerfile.sandbox-<domain>

The runtime image does not depend on a domain. It contains gcc, openssl-devel, python3, git, and jq, and no other measurement tools. The sandbox contains the agent CLI and Python. The domain image must add all other tools that measure_cmd or the agent turn needs. examples/selfhost (Rust) is the example: docker/Containerfile.runtime-rust and docker/Containerfile.sandbox-rust.

What the two images must satisfy

Loop pod (Containerfile.runtime-<domain>)

  • Use FROM ghcr.io/neuralmagic/crucible:<tag>. Keep WORKDIR /opt/crucible. Do not replace /usr/local/bin/{crucible,openshell,openshell-gateway}.
  • Install the toolchain in a world-readable location (/usr/local/...), not under /root. On OpenShift, the pod runs as an arbitrary uid.
  • Pre-fetch the dependencies that measure_cmd builds against. The loop pod has egress (crates.io answered 200 from crucible-system on waldorf on 2026-08-25), but a cold dependency fetch in the first measure is slow and often fails first.
  • dnf is available (full UBI10).

Agent sandbox (Containerfile.sandbox-<domain>)

  • Use FROM quay.io/aipcc/agentic-ci/claude-sandbox:<tag>. It is a minimal UBI10: it has rpm but no dnf and no compiler. The uid is sandbox (998), HOME=/sandbox, and claude is at /usr/local/bin/claude.

  • To add RPMs, install them into a rootfs in a full UBI10 stage of the same release, and copy that rootfs over the image:

    FROM registry.access.redhat.com/ubi10/ubi:10.2 AS pkgs
    RUN dnf install -y --nodocs --installroot /mnt/rootfs --releasever 10 \
            --setopt install_weak_deps=false gcc glibc-devel binutils ... \
        && dnf clean all --installroot /mnt/rootfs
    FROM quay.io/aipcc/agentic-ci/claude-sandbox:0.3.11
    COPY --from=pkgs /mnt/rootfs/ /
    

    perf is not in the UBI repositories. Sampling profilers with their own sampler (samply) work. cargo flamegraph does not work.

  • Install the toolchain under /usr/local, run chmod -R a+rwX on its caches, and put USER sandbox at the end. If you set ENV PATH, include the bin directory of the toolchain. The agent inherits it.

  • Pre-fetch more dependencies here than in the loop pod. The sandbox has deny-by-default egress. A cargo add or pip install by the agent during a turn works only if the registry is on the [agent.openshell].endpoints allowlist of the manifest and the binary that fetches is in [agent.openshell].binaries (for example, /usr/local/cargo/bin/cargo). Files that are already in the image cache need neither.

Build

Both images are amd64 and large (compiler and dependency fetch). Build them on the cluster, not under QEMU:

buildit build quay.io/<you>/crucible-sandbox-<domain>:v1 -n weaton-dev \
    --kubecontext coreweave-waldorf --mode job -f Containerfile.sandbox-<domain> \
    --request cpu=8 --request memory=16Gi
buildit wait <job> -n weaton-dev --kubecontext coreweave-waldorf   # last line: digest-pinned ref

.dockerignore keeps target/, .claude/, and the example workspaces out of the build context. Pin CRUCIBLE_TAG for the runtime image (--build-arg CRUCIBLE_TAG=<tag>). If you do not, the engine version in the loop pod can be different from the controller version.

A new quay repository is private. The namespace needs an imagePullSecrets entry (a kubernetes.io/dockerconfigjson secret from ~/.docker/config.json) before the pod can pull the image. Without it, the pod stays in ImagePullBackOff with a 401.

Verify the images

Before a manifest uses an image, run the image as an unexpected uid. Check the toolchain, the cache, and an offline build:

kubectl run smoke --restart=Never --image=<ref> \
    --overrides='{"spec":{"imagePullSecrets":[{"name":"quay-pull"}],"securityContext":{"runAsUser":12345,"runAsGroup":0}}}' \
    --command -- sh -c 'id; cargo --version; cd /tmp && cargo new -q t && cd t && cargo build -q --offline && ./target/debug/t'

Then run crucible check --manifest <pack>/crucible.toml locally. It runs measure_cmd and the gate self-test. Set backend = "openshell" and sandbox_image, and deploy.

Running the controller locally

The controller can run on a laptop without an identity provider, Vault, or a cluster. Playbook launches run as crucible plan run subprocesses on the same machine. If the agent turns of a pack need an OpenShell sandbox, the engine starts one on the podman of the host.

just controller-local

Then open http://127.0.0.1:8870. The recipe needs bun and a Rust toolchain. It needs podman only for OpenShell sandboxes. The recipe:

  • builds the UI and the crucible, crucible-controller (with embedded-db), and crux binaries;
  • runs crucible-controller autopilot against its own Postgres, with the settings below.

just controller-local 9000 wren uses a different port and login. If no other address is configured, crux uses http://127.0.0.1:8870, so on the default port it needs no setup:

crux whoami
crux draft-create notes --description "release notes"
crux draft-push notes examples/playbook --base-version 1
crux draft-launch notes --max-cost 1 --max-time 5m

What the recipe sets

VariableValueWhy
DATABASE_URLembeddedThe controller runs its own Postgres.
CONTROLLER_STATE_DIR$XDG_STATE_HOME/crucible-controllerThe location of the database and the local runs.
CONTROLLER_DEV_IDENTITY$USEREvery request uses this login.
CONTROLLER_ADMINS$USERMakes that login an admin, so you can launch and edit in the UI.
CONTROLLER_PLAYBOOK_EXECUTORlocalLaunches run as a local subprocess, not as a work pod.
CONTROLLER_SCOPE_EXECUTORdisabledDisables autopilot scoping, which would otherwise run crucible scope automatically.
CONTROLLER_SESSION_SECUREfalseThe UI is plain http.
KUBECONFIG/dev/nullThe controller does not connect to the cluster in your kubeconfig.
OPENSHELL_PODMAN_SOCKETthe API socket of the podman machineThe engine starts OpenShell sandboxes here. Set it to use a different socket.
CRUCIBLE_BINthe built crucibleThe engine a launch runs.
CONTROLLER_HOOKS_ADDR127.0.0.1:8871 (set HOOKS_PORT to change it)The webhook delivery listener, on a separate port.
CONTROLLER_HOOKS_PUBLIC_URLhttp://127.0.0.1:8871The base of the delivery URL of each new webhook.
CONTROLLER_WEBHOOK_KEY_FILE$CONTROLLER_STATE_DIR/webhook.key, generated onceSeals hmac_sha256 webhook secrets.

The recipe unsets CONTROLLER_API_TOKEN, CONTROLLER_OIDC_ISSUER, and VAULT_ADDR for the process, also if your shell exports them.

The embedded database

With DATABASE_URL=embedded, crucible-controller autopilot runs its own Postgres 16 and does not connect to an external one. It needs a build with the embedded-db feature (cargo build -p crucible-controller --features embedded-db). A build without the feature rejects the value at boot.

  • On the first start, the controller downloads the Postgres binaries one time into ~/.theseus/postgresql. All embedded controllers on the machine share them.
  • The data directory is $CONTROLLER_STATE_DIR/postgres/data, and its password is in postgres/pgpass next to it. The data stays after a restart. To start with an empty database, delete postgres/.
  • At each start, the server listens on a free loopback port. Only the controller uses it. crux and the UI use the controller API.
  • The server stops when the controller exits. If the controller is killed, the next start stops the old server before it starts a new one.

The dev identity

Without CONTROLLER_API_TOKEN and CONTROLLER_OIDC_ISSUER, the controller starts only with CONTROLLER_DEV_IDENTITY. The guard is then open: it admits every request as that login, and binds loopback, whatever CONTROLLER_API_ADDR is. The controller removes any X-Auth-Request-* header from the client, so a browser and curl get the same identity. The controller does not boot if this variable is set on a guarded deployment, because it would give every caller that identity, and it does not boot with none of the three, because an open controller without a login is never what a deployment meant.

Agent turns

A local run starts with an empty environment. It keeps only PATH, HOME, USER, OPENSHELL_PODMAN_SOCKET, and the CRUCIBLE_* variables from the controller.

  • backend = "local" (the default) runs the agent CLI on this machine, under your own login. The harness finds its configuration through HOME and USER. This costs real money.
  • backend = "command" runs the command of the pack, without a model.
  • backend = "openshell" runs each turn in a sandbox from sandbox_image, which the engine starts on podman. It needs openshell and openshell-gateway on PATH. Podman must be able to pull the image, or the image must already be in podman. just revise-loop-e2e builds an image without a model.

Put any other variable that a run needs in CONTROLLER_LOCAL_SECRET_ALLOWLIST. The pack must disclose the variable as an agent credential before a run gets it.

Webhooks

The recipe serves the webhook delivery listener on 127.0.0.1:8871. Launching from webhooks shows a pack with only commands, from a curl delivery to a finished run.

What is disabled

  • By default, the build does not include the autoresearch lane (see Deploying the controller), so the UI shows only playbooks.
  • Without Vault, the write routes of the secrets registry answer 503. A pack that binds no secrets launches normally. A launch of a pack that binds secrets fails.
  • Runtime overrides (the live caps on the admin page) need the overrides ConfigMap, so the controller uses the configuration that it parsed at start.
  • Discovery, triage, and the work-pod paths have no cluster and no watched repositories, so they do nothing.

Deploying the controller

The controller is the control plane around the engine. It has a Postgres ledger of issues, scopes, runs, and launches, a daemon that reconciles them into loop pods, and an HTTP API. The API serves the embedded UI, /api, and the hosted MCP server at /mcp. Run one replica per cluster. The binary is crucible-controller. It ships as ghcr.io/neuralmagic/crucible-controller, built on the runtime image, so the image also contains the engine binaries that the controller launches.

This page describes what the controller needs and how its parts connect. Authentication describes identity, and Vault describes the secrets registry. To try the controller on a laptop first, see Running the controller locally.

Requirements

RequirementSettingNotes
PostgresDATABASE_URLThe daemon migrates the schema when it opens the database. There is no separate migrate step. crucible-controller db verify tests the migration set against a scratch database. embedded runs a separate Postgres, for local use.
A bind addressCONTROLLER_API_ADDRWithout CONTROLLER_API_TOKEN, the API binds only loopback, whatever address you set.
A static bearerCONTROLLER_API_TOKENThe deployment's machine credential, for CD and the cluster-internal Service. Required for any routable bind.
A GitHub tokenGITHUB_TOKENDiscovery and triage use it to read the watched repositories. Public repositories need only public read access.
Cluster accessin-cluster ServiceAccountPod get/list/watch/create in the loop namespace (CONTROLLER_POD_NAMESPACE). Spokes are separate, below.
Scratch spaceCONTROLLER_SCRATCH_DIRRepository checkouts and the flow cache. The controller can create them again. An emptyDir is sufficient.
A public URLCONTROLLER_PUBLIC_URLThe base URL for the UI, the MCP handoff, and emitted links.

All durable state is in Postgres. You can replace the controller pod at any time.

Running it

export DATABASE_URL=postgres://crucible:...@postgres:5432/crucible
export CONTROLLER_API_ADDR=0.0.0.0:8080
export CONTROLLER_API_TOKEN=$(openssl rand -hex 32)
export CONTROLLER_PUBLIC_URL=https://crucible.example.com
export CONTROLLER_WATCHED_REPOS=your-org/your-repo
export CONTROLLER_POD_NAMESPACE=crucible-loops
export GITHUB_TOKEN=ghp_...
crucible-controller autopilot

autopilot is the resident daemon. It does discovery, ranking, scoping, launching, and recording, and it serves the API. autopilot --once processes the queue and exits, for a one-time reconcile. Each flag has a CONTROLLER_* environment variable. crucible-controller autopilot --help lists all of them with their defaults.

The admission caps limit what the daemon can spend without a person:

VariableDefaultBounds
CONTROLLER_MAX_CONCURRENT_PODS2loop pods running at once
CONTROLLER_MAX_SCOPES_PER_DAY20scopes admitted per UTC day
CONTROLLER_DAILY_COST_CEILING_USD50agent spend per UTC day
CONTROLLER_PER_RECONCILE_COST_USD10spend one reconcile may commit

You can disable autopilot at run time on the admin page. This pauses only the spend that the daemon starts.

The autoresearch lane

Playbooks are the core of the controller: the registry, drafts, launches, schedules, watches, and their runs. The autoresearch lane is separate and disabled by default. It contains GitHub discovery and triage, ranking, scope proposal and its approval gate, image builds, and scored loop runs. Two settings enable it:

  • the autoresearch cargo feature, which compiles the lane (cargo build -p crucible-controller --features autoresearch). The published image and release binaries include it.
  • CONTROLLER_AUTORESEARCH=true, which enables the lane in a build that has it. If the variable is set on a build without the feature, the controller does not start.

When the lane is disabled, the controller does not serve its routes or run its discovery polls, and it does not change rows that are not playbook launches. GET /api/version reports autoresearch, and the UI hides the pages of the lane. The engine has a matching autoresearch feature for the scored loop and crucible scope. A deployment that enables the lane needs both.

On Kubernetes

The controller runs as a Deployment. In a cluster, replicas compete for a Lease (CONTROLLER_LEASE_NAME), and only the holder reconciles. During a rolling update, two daemons do not apply the check-then-act caps at the same time. CONTROLLER_LEADER_ELECTION=off disables leader election for a single-replica deployment that does not use a Lease.

A minimal set of objects:

  • a ServiceAccount for the controller, with a Role in the loop namespace bound to it: pods get/list/watch/create/delete and pods/log get (dispatch, the watch, and the log relay); pods/finalizers update (the pod's ConfigMap and Secret block its deletion, which OpenShift's owner-reference admission checks); configmaps and secrets create/delete (the pack ConfigMap and the run Secret, which holds the pod token and the delivered secrets, both owner-referenced to the pod); and leases get/create/update/patch for leader election;
  • a Secret for CONTROLLER_API_TOKEN and one for GITHUB_TOKEN, referenced by env;
  • a Service on the API port;
  • the Deployment, env from the table above, emptyDir at CONTROLLER_SCRATCH_DIR.

Before you expose the Service outside the cluster, put an OIDC login in front of it. See Authentication. You can expose the machine paths (/api, /mcp) on a separate hostname, because they authenticate with a bearer token.

The webhook delivery listener is a third endpoint, on a separate port. It is the only part that you publish to the internet. Launching from webhooks gives its variables and the webhooks values of the chart.

The image labels contain the contract version of the engine (io.crucible.contract-version). Before the controller launches a pod, it reads the same label from the dispatch image. If the versions are different, it does not launch the pod.

Where loops run

By default, loop pods run in the cluster of the controller, in CONTROLLER_POD_NAMESPACE. A deploy profile (CONTROLLER_DEPLOY_PROFILE, a TOML file) sets the cluster values that a launch needs: the loop image, pull secrets, the service account, the results bucket, and the environment for the engine. The engine renders the loop pod from the profile in the controller process, so the controller validates the profile when it starts, not when a launch fails.

If the controller runs loops in other clusters (spokes):

  • CONTROLLER_CLUSTERS_DIR holds one kubeconfig for each spoke. CONTROLLER_DISPATCH_CLUSTER names the default target. CONTROLLER_DISPATCH_CLUSTER_BY_CONTRACT selects the cluster by the broker contract of the pack.
  • CONTROLLER_CLUSTER_POLICY limits which users can target which cluster (name=group:/path;user:login, comma-separated).
  • If the hub cannot reach a spoke, the spoke sends reports to the hub. crucible-controller cluster-snapshot runs on the spoke as a CronJob and posts to the push endpoint of the hub with a bearer token that the hub lists in CONTROLLER_PUSH_TOKENS.

Agent tokens

The controller can act as the OAuth authorization server for sandboxed agents. An OpenShell sandbox supervisor posts its SPIRE JWT-SVID to POST /oauth/token (client credentials with a jwt-bearer client assertion, no client_id); the controller verifies it against SPIRE's JWKS, maps the sandbox to the run whose pod bound it, and returns an ES256 access token living 15 to 30 minutes. MCP servers verify it against /.well-known/jwks.json; /.well-known/oauth-authorization-server describes the endpoints. All three are served outside the human auth stack.

The token's sub is the person the run was dispatched for: whoever unparked, scoped, redispatched, or approved the issue for that dispatch, else the launch's creator (the owner, for a schedule, watch, or webhook). A GitHub review approval counts only when an operator paired the reviewer's GitHub login with a crucible login in CONTROLLER_GITHUB_LOGINS (<github login>=<crucible login>, comma-separated); equal names are never assumed to be the same person. The request is consumed by the run it gets, so later runs act for the owner again. An override by an actor that is not a login records nobody. act.sub is the sandbox's SPIFFE ID. It also carries run_id (the run pod), sandbox (the sandbox's name), workdir (its workspace path), kube_user (below) and, for runs on the [kueue] cluster, namespace (crucible-kueue's default namespace for the run's workloads). Claims name who is calling and for which run; what that identity may do is the resource server's to decide (crucible-kueue runs SubjectAccessReview as kube_user). Refusals return only the OAuth error code and a fixed description; the reason is logged.

Sandbox placement. A sandbox's SPIFFE ID is only as trustworthy as whoever can create pods where its supervisor runs, so with agent clusters the controller refuses to start unless the deploy profile runs each run's gateway in its own pod: [cluster] sandbox_namespace (which means the kubernetes driver), which profile load refuses to share with the loop, rig, or crucible-kueue namespaces. It also refuses to start the issuer when its own namespace, where the signing key Secret is mounted from, is any of those namespaces or [buildit] namespace (the loop account can exec into any pod there), and when a SPIRE's namespace in CONTROLLER_AGENT_SPIRE is one pack code reaches (loop, rig, sandbox, buildit or crucible-kueue workload namespace), since a pod there could exec into the SPIRE server and mint any SVID. Its namespace is POD_NAMESPACE, else the service account's namespace file; a controller rendered by crucible deploy render runs in the loop namespace and so cannot hold the key.

Gateway identity. Each run's gateway has its own OpenShell gateway id, the run pod's name. The gateway stamps it as openshell.ai/gateway-id on every supervisor pod and Sandbox it creates, from its own config, and driver_config cannot set labels or annotations. Scope the sandbox ClusterSPIFFEID to the sandbox namespace and build the ID from that label and the supervisor's openshell.ai/sandbox-id annotation:

spiffeIDTemplate: 'spiffe://{{ .TrustDomain }}/openshell/gateway/{{ index .PodMeta.Labels "openshell.ai/gateway-id" }}/sandbox/{{ index .PodMeta.Annotations "openshell.ai/sandbox-id" }}'

Each cluster's gateway prefix is everything before the gateway id (spiffe://<trust domain>/openshell/gateway/, from its discovered trust domain, or its CONTROLLER_AGENT_SPIRE entry). The controller reads that SPIRE's keys from its OIDC discovery provider through the cluster's API server Service proxy, with the same client it dispatches with, so its identity on each cluster needs get on services/proxy in SPIRE's namespace. The iss of an SVID picks the cluster whose keys verify it, and a token is issued only when the sandbox's run is on that cluster.

The engine binds each sandbox it creates by posting its id and workdir to /api/sandbox-bindings/runs/{run} with the run's pod token (CRUCIBLE_POD_TOKEN, the same token that uploads its evidence), and only when the loop pod carries CRUCIBLE_AGENT_TOKEN_ENDPOINT. A spoke that reaches the controller through an external route needs /api/sandbox-bindings and /oauth/token there, beside /api/evidence and the issuer's /.well-known/jwks.json. The controller binds only a Sandbox resource (agents.x-k8s.io) in the sandbox namespace carrying that id and the run's gateway id, reads the sandbox's name from it, and owner-references it to the gateway pod. At token issue it refuses an SVID whose gateway id is not the bound run's. Name the controller's identity on each cluster in [cluster] sandbox_readers (namespace/name) and the loop render grants what it needs (below). A sandbox that was never bound, whose run pod is no longer running, or whose SVID names another run's gateway gets invalid_grant.

Isolated gateways. A run's OpenShell gateway does not run in the loop pod. The loop render creates the crucible-openshell-gateway ServiceAccount in the sandbox namespace and binds the sandbox rights (Sandboxes, pods, secrets) to it alone; the loop service account gets no verbs there. For each run the controller first creates the gateway Service in the sandbox namespace and mints a CA, a server certificate for that Service's ClusterIP, and client certificates for the loop pod and the supervisors, valid for the run's deadline plus a day (30 days without one); it keeps no copy of the CA key. The loop pod's client material and the gateway URL arrive through the run Secret. Before the loop pod it creates a loop Service (the sandboxes' host.openshell.internal), a NetworkPolicy admitting only that run's loop pod and its own supervisors to the gateway port, the gateway pod, and the server and supervisor TLS Secrets owned by that pod. Collection deletes the gateway's Sandboxes and supervisors, the loop Service, then the gateway's own objects; a sweep on the discovery tick does the same for any gateway whose run pod is no longer running. Dispatch to a cluster the hub only receives pushes from is refused, since the hub cannot create the gateway there.

For the controller's identity, sandbox_readers is granted: sandboxes get/list/patch/delete; pods get/list/create/delete and pods/finalizers update (the gateway's Secrets block its pod's deletion); services, secrets and networkpolicies list/create/delete in the sandbox namespace; and services list/create/delete in the loop namespace.

Grounded-rank and scope turns get a gateway of their own the same way, with certificates valid for the turn's deadline; a failed launch tears it down, and the gateway sweep removes it once the turn's row closes. Turn sandboxes are never bound, so they get no agent tokens.

Supervisors of runs that get agent servers mount the SPIFFE Workload API (the csi.spiffe.io driver) at /spiffe-workload-api/spire-agent.sock; [cluster] sandbox_spiffe_socket names another path. Runs without agent servers mount none, so a cluster without SPIRE still starts sandboxes.

With [cluster] aws_sandbox_role_arn, the gateway pod assumes that role with an STS-audienced token for its own service account, as the in-pod gateway does with the loop pod's, so the role's trust policy must allow system:serviceaccount:<sandbox namespace>:crucible-openshell-gateway.

Pack code holds its run's gateway client certificate, so it can still create sandboxes for its own run with the driver_config pod fields the gateway accepts (node_selector, tolerations, host_aliases, runtime_class_name, priority_class_name, PVC volumes); resource admission keeps PVCs and priority classes to those labelled openshell.ai/sandbox-attachable=true, and those sandboxes carry its own gateway id. The pinned OpenShell (3441f91) predates v0.1.2: supervisors are separate pods, and nothing here depends on the sidecar supervisor topology or other v0.1.2 features.

kube_user. Tokens carry the dispatcher's Kubernetes username on the run's cluster when the controller knows it, by the same rule people's tokens use there (Settings > Cluster identities, Cluster identities); a server that sees it runs SubjectAccessReview as that user. A link cluster's username is the one the person linked there. On the hub a platform administrator can override it with PUT /api/users/{login}/kube-user ({"kube_user": "<name>"}, or null to clear it); the override is audited on user:<login>, and no administrator sets their own. system: identities are refused. crucible-kueue acts only as a token's kube_user, so a token carrying crucible-kueue scopes is refused when the username is unknown; other tokens are issued without the claim, logged at warn. The linking and override routes are served whenever the controller mints agent tokens, people tokens, or has a [cluster_link] table.

The issuer needs CONTROLLER_AGENT_ISSUER, a signing key and the deploy profile. Audiences, people resources, each cluster's SPIRE and platform, the servers' trusted issuer and the cluster link callback are derived from those; the rest of the table is overrides. See Minimal configuration and the derived values beside it.

VariableMeaning
CONTROLLER_AGENT_ISSUERThe issuer identifier, an origin such as http://crucible-controller.crucible-system.svc.cluster.local:8080. Unset turns the endpoint off. Supervisors must request their SVID with exactly this audience (token_grant.jwt_svid_audience).
CONTROLLER_AGENT_SIGNING_KEY_FILEA PKCS#8 P-256 private key mounted from a Secret. The kid is its RFC 7638 thumbprint.
CONTROLLER_AGENT_SIGNING_KEYThe same key inline, as PEM, in place of the file.
CONTROLLER_AGENT_PREVIOUS_SIGNING_KEY_FILEOptional: the key before a rotation. It is published in the JWKS beside the current key, so tokens it signed keep verifying until they expire; nothing new is signed with it. Rotate by moving the current key here and mounting a new one, then drop this after one token lifetime.
CONTROLLER_AGENT_TOKEN_TTL_SECSAgent token lifetime, 900 to 1800 seconds. Default 900.
CONTROLLER_AGENT_TOKEN_ENDPOINTWhere sandbox supervisors reach the token endpoint when not at the issuer itself (a spoke reaching the controller through a route). Default <issuer>/oauth/token.
CONTROLLER_AGENT_SPIREOptional: by default each cluster an agent server serves has its SPIRE discovered through the controller's own cluster client (OpenShift: the Zero Trust Workload Identity Manager's ZeroTrustWorkloadIdentityManager and SpireOIDCDiscoveryProvider named cluster, its discovery Service in zero-trust-workload-identity-manager on https:443; elsewhere: the spire-server ConfigMap's server.conf, its discovery Service in spire-server on http:80), and GET /api/agent-clusters shows each cluster's state. For a SPIRE installed elsewhere, comma-separated <cluster>=<svid issuer> <gateway prefix> [http://]<namespace>/<service>:<port> entries (hub for the controller's own): the iss of that SPIRE's JWT-SVIDs, its gateway prefix (spiffe://<trust domain>/openshell/gateway/; supervisor IDs are <prefix><gateway id>/sandbox/<sandbox id>; one path segment may be {tenant}, as in spiffe://<trust domain>/openshell/{tenant}/gateway/, to match IDs with a DNS-label tenant there), and its OIDC discovery provider Service, whose /keys is read through that cluster's API server (over https, or http when the Service is prefixed http://). Each cluster and each issuer appears once. SVIDs must carry iat and live at most 15 minutes; SPIRE's upstream chart issues 1-hour JWT-SVIDs by default, so give the supervisors' ClusterSPIFFEID jwtTtl: 5m.
CONTROLLER_PEOPLE_ACCESS_TTL_SECSPeople's access token lifetime, 1 to 3600 seconds. Default 900.
CONTROLLER_PEOPLE_CLIENTS_FILEA TOML file of [[clients]] (client_id, name, redirect_uris, optional resources): the public clients people may sign in with. It replaces the built-in claude-code, pi and codex, so list those too to keep them. Unset keeps the built-ins. A malformed file, or a resources entry that is no people resource, stops the controller at startup. See MCP clients.
CONTROLLER_PEOPLE_DCRfalse turns off self-registration (POST /oauth/register, RFC 7591, loopback public clients only), and the metadata then advertises no registration_endpoint. On by default when the deploy profile serves people, which is what lets pi, Codex and Claude Code sign in without a client id. Registrations idle for 30 days are deleted; each source address may register 20 times an hour, and about 10000 live at once (a soft cap across replicas). See Self-registered clients.
CONTROLLER_PEOPLE_TRUSTED_PROXIESComma-separated networks or addresses (10.128.0.0/14) whose X-Forwarded-For names a registration's source address, such as the cluster router's. Unset, every registration counts against its TCP peer.
CONTROLLER_PEOPLE_CIMD_HOSTSComma-separated host names (chatgpt.com,pi.dev) allowed to publish Client ID Metadata Documents. Set, an https:// client_id on one of these hosts is fetched and accepted as a public client, and the metadata advertises client_id_metadata_document_supported. Unset (the default) refuses every URL client_id. The controller needs outbound HTTPS to these hosts; it honours HTTPS_PROXY.

A run dispatched onto one of the [kueue] clusters (clusters = ["spoke-a", "spoke-b"], or cluster = "spoke-a"), or of any server the profile describes, gets each server's name, URL, scopes and transport (CRUCIBLE_AGENT_SERVERS), the token endpoint (CRUCIBLE_AGENT_TOKEN_ENDPOINT) and the issuer as SVID audience (CRUCIBLE_AGENT_SVID_AUDIENCE) as plain env. The engine registers a token-grant provider for each, so each sandbox's supervisor obtains its own token and injects it on traffic to the server alone; no token is minted per run. A server is granted only on a cluster whose SPIRE the issuer trusts. The run records what it got, with the server's scopes and, for a server that acts as kube_user such as crucible-kueue, the dispatcher's username there, and the run page shows it. A third server needs an [[agent_servers]] entry with its url and scopes, nothing else.

The deploy profile describes each server that takes agent tokens (DeployProfile::agent_servers): [kueue] needs a kube_user in every token and names workload_namespaces, the first as the namespace claim, for runs on its clusters; [buildit] needs neither. Each server's URL on each of its clusters is an agent audience, with its scopes: kueue.read kueue.submit for crucible-kueue and buildit.read buildit.build for buildit. The rendered server trusts runs' tokens from the controller's CONTROLLER_AGENT_ISSUER (crucible deploy render --kueue --issuer <it>), fetching keys from <issuer>/.well-known/jwks.json unless run_jwks_uri names another URI (a route, for a server that cannot reach the issuer's address). kube_authz (openshift or token) is where the server takes a caller's groups for its SubjectAccessReviews. With people = true the rendered server also accepts people's tokens from the same issuer (--person-issuer), and each cluster's server becomes a resource people's MCP clients get tokens for, at its Service URL or the cluster's people_urls entry.

buildit. A run dispatched onto one of the [buildit] clusters starts buildit mcp in its loop pod on port 8848, and its sandboxes reach it at http://host.openshell.internal:8848/mcp with tokens their supervisors obtain like crucible-kueue's. buildit takes only tokens for that audience whose run_id is its own run, reads each build's context from the token's sandbox and workdir through the run's gateway, and runs builder pods in [buildit] namespace, which profile load refuses to share with the loop, rig, sandbox or crucible-kueue namespaces. The loop render grants the loop service account pods create/get/list/watch/delete, pods/exec and pods/log there and nowhere else, since those rights in the loop namespace would reach every run's loop pod and Secret. Create the namespace before the first run; builder pods run as its default service account, so on OpenShift bind that account to an SCC that admits them (uid 1000). The rights still let runs see each other's builder pods in that namespace, including a push's authfile while it is copied in; buildit under a service account of its own is the clean end state. A build's RUN steps run the agent's code unsandboxed in the builder pod, so the render also creates the buildit-builders NetworkPolicy there: no ingress, egress only to DNS and [buildit] egress (default TCP 443 anywhere). Policies match networks, not host names: list your registries' networks, or put the cluster's pod and service networks in except, to keep builds off in-cluster services. push publishes a succeeded build to push_repo only, tagged <sandbox>-<build_id>, with the loop pod's [secrets] push_authfile; without either, buildit pushes nothing. queue submits builder pods to that Kueue LocalQueue in namespace; set it where Kueue manages the namespace and holds pods that name no queue (manageJobsWithoutQueueName), or builds wait 10 minutes and fail. [buildit.builders] sizes builder pods on every cluster: node_arch (their kubernetes.io/arch), max_builds live per sandbox, pod_deadline_secs, requests/limits, and storage_driver. [buildit.cluster_builders.<cluster>] replaces any of those on one cluster, so a mixed-architecture cluster can pin its builds. Without node_arch, builder pods run on the loop pod's architecture; unset limits are buildit's defaults (4 builds, 7200 s, no requests or limits). storage_driver is vfs (the default), a full copy of the filesystem per image layer, or overlay, each layer stored once with kernel overlayfs in buildah's user namespace; overlay needs a kernel that allows that (5.13 and later, such as RHEL 9's).

[buildit]
clusters     = ["spoke-a", "spoke-b"]
namespace    = "crucible-builds"                    # builder pods, apart from loop pods
egress       = [{ cidr = "0.0.0.0/0", except = ["10.128.0.0/14", "172.30.0.0/16"] }]  # optional
run_jwks_uri = "https://crucible-ext.example/.well-known/jwks.json"  # optional
push_repo    = "registry.example.com/team/crucible-smoke"  # optional
queue        = "crucible-builds"                    # optional, a Kueue LocalQueue in namespace
image        = "ghcr.io/neuralmagic/crucible-buildit@sha256:..."  # optional, default the loop image's

[buildit.builders]                                  # optional
max_builds        = 2
pod_deadline_secs = 3600
requests          = { cpu = "8" }
limits            = { cpu = "32" }
storage_driver    = "overlay"

[buildit.cluster_builders.spoke-b]                  # optional, per cluster
node_arch = "amd64"

[buildit.cache]                                     # optional, a cache builds share
clusters     = ["spoke-a"]                          # default the hub; deploy it on each
token_secret = "buildit-cache-token"
size_gib     = 500
write        = true

image pins buildit apart from the loop image: loop pods copy its /usr/local/bin/buildit in with an init container and run it ahead of their own, and the cache runs it unless [buildit.cache] image says otherwise. Every main build publishes ghcr.io/neuralmagic/crucible-buildit, so a buildit-only change rolls by moving this pin; the controller reads the profile at each dispatch, and the cache needs crucible deploy --buildit-cache applied again.

[buildit.cache] adds a buildit cache in the loop namespace that every build may use: ccache objects and stored build outputs shared across runs. crucible deploy --buildit-cache --profile <profile> renders its PersistentVolumeClaim, Deployment, Service and a NetworkPolicy that admits builder pods only; apply it on each cluster in clusters. The loop render opens builder egress to it, and runs on those clusters, and no others, get its URL. Create the token Secret first, in the loop namespace, with a URL-safe value:

oc create secret generic buildit-cache-token --from-literal=token="$(openssl rand -hex 32)"

With write (the default) loop pods mount the token and builds on the cache's clusters may write to it. A build's RUN steps can read it, so one run's agent could store objects other runs' builds use: keep write for clusters whose runs trust each other, and set write = false otherwise so builds read only.

openssl ecparam -genkey -noout -name prime256v1 | openssl pkcs8 -topk8 -nocrypt -out agent-signing.pem
oc create secret generic crucible-agent-signing-key --from-file=agent-signing.pem

Observability

  • /metrics serves Prometheus text on the API port.
  • Tracing goes to stderr as leveled tracing events. Set OTEL_EXPORTER_OTLP_ENDPOINT to also export OTLP spans over HTTP.
  • /healthz answers when the database is reachable.
  • The GET /api/system payload names the commit the binary was built from.

The API and the CLI

GET /api/openapi.json is the API document. It needs the same bearer token as the other routes. crux is the CLI for that API and the tool library that the controller hosts at /mcp:

cargo install --locked --git https://github.com/neuralmagic/crucible.git crux
export CONTROLLER_URL=https://crucible.example.com
export CONTROLLER_API_TOKEN=crk_...
crux whoami

You get the crk_ key on the Settings page of the UI. It has the identity of its owner. The static CONTROLLER_API_TOKEN also works on /api, as anonymous or as the identity that CONTROLLER_API_TOKEN_IDENTITY sets. It does not work on /mcp. The crux README is the only reference for its flags, environment variables, and configuration file.

Authentication

Three kinds of callers use the controller. Each kind has its own credential:

CallerCredentialReaches
A person in the browsera session that the controller creates after an OIDC loginthe UI and /api
The tools of a person (crux, an MCP client)an API key (crk_...) from the Settings page/api and /mcp
The automation of a deployment (CD, sidecars)the static CONTROLLER_API_TOKEN/api only

Authorization is separate from the credential. Teams, ownership, and the policy set (RFC-0003) decide what each caller can do, from the login and groups in the credential. On a new deployment, four lists set the first members:

  • CONTROLLER_ADMINS: logins that administer the platform;
  • CONTROLLER_OPERATORS: logins that can operate;
  • CONTROLLER_OPERATOR_GROUPS: IdP groups whose members can operate;
  • CONTROLLER_PUBLISHERS: logins that can publish their own drafts without review.

These lists seed the platform-administrators, platform-operators, and playbook-publishers teams while those teams have no members. A login on the admin or operator list has that team membership on every request, also if the team does not list it. Membership that you add in the UI has the same effect. An owner of platform-administrators is an admin. Any other member of platform-administrators or platform-operators is an operator. This applies to the routes that need admin or operator, and to the role that /api/whoami reports.

Each team has a workspace at /teams/<slug>. Each list has a team path (for example /teams/<slug>/secrets) that shows only the resources of that team.

Opening a deployment to an organization

A signed-in user who is in no team can read and launch the playbooks and providers that team:platform-administrators owns, and dispatch to its targets. The user owns all resources that they create. To give one organization more access, without giving it to every login that the issuer accepts, create a team whose members come from a rule. Then give the shared resources to that team. The Teams page makes the first call:

POST /api/teams {"slug": "my-org", "display_name": "My org",
  "members": [{"kind": "rule", "member": "group-suffix:my-org-group", "role": "member"},
              {"kind": "user", "member": "<you>", "role": "owner"}]}
PUT /api/playbooks/<id>/owner {"owner": "team:my-org"}
PUT /api/providers/<id>/owner {"owner": "team:my-org"}

Members can read, launch, and dispatch what the team owns. Maintainers and owners can also edit it. A provider uses its key on every dispatch, so each user who can select the provider spends with that key. Move a provider to a team only if the whole team must use it. email-domain:<domain> rules match the email that the controller records at each login.

The autoresearch lane

On a deployment that runs autoresearch (CONTROLLER_AUTORESEARCH=true), only entitled callers see the pages and routes of the lane (issues, inbox, turns, builds, repos, explore, the autopilot switch). Other callers get a 404, and their navigation does not show these pages. The entitlement is read on the lane, which the autoresearch team owns. Its members have the entitlement, platform administrators have all entitlements, and a policy set can give it to other users. The controller seeds the team with platform-operators as a nested team. To give a person access to the lane, add a group rule or the person to the team. /api/whoami reports the entitlements of the caller under entitlements.

Viewing as another user

A platform administrator (platform:impersonate) can view the controller as any user who has signed in, from the row of the user on a team page. The browser session then uses the identity of that user, with the groups from their last sign-in, for one hour or until the administrator stops it. The view is read-only. The controller rejects every write except DELETE /api/impersonation. The event log records the start and the stop under the login of the administrator. API keys and bearer tokens cannot use a view.

The controller as the OIDC relying party

With CONTROLLER_OIDC_ISSUER set, the controller serves /auth/login, /auth/callback, and /auth/logout. It runs the authorization-code flow with PKCE against the issuer, validates the ID token against the JWKS of the issuer, and creates its own server-side session (a cookie id and rows in Postgres). There is no proxy in front, and the controller reads no identity header on any path: it removes every X-Auth-Request-* header from each request before it authenticates the request.

VariableMeaning
CONTROLLER_AUTH_MODEOptional. native is the only accepted value.
CONTROLLER_OIDC_ISSUERThe issuer URL. The controller reads discovery from <issuer>/.well-known/openid-configuration.
CONTROLLER_OIDC_CLIENT_ID, CONTROLLER_OIDC_CLIENT_SECRETThe confidential client registered on the issuer.
CONTROLLER_OIDC_REDIRECT_URLA callback URL on the host of this controller, registered on the client. The controller answers at that path and at /auth/callback.
CONTROLLER_OIDC_SCOPESDefault openid,email,profile,offline_access. Do not add groups. The claim comes from a client mapper, not a scope, and an unregistered scope makes every login fail.
CONTROLLER_OIDC_POST_LOGOUT_REDIRECTThe page that the browser opens after RP-initiated logout.
CONTROLLER_SESSION_SECURESet true behind TLS. The cookie is then Secure and SameSite=Lax.

The session holds the login, the email, and the groups claim from the issuer. Team membership rules match on groups. If the issuer emits no groups claim, no user gets access through groups, and there is no error. Test the claim before you point a deployment at the issuer:

CONTROLLER_OIDC_ISSUER=https://sso.example.com/realms/crucible \
CONTROLLER_OIDC_CLIENT_ID=crucible-controller \
CONTROLLER_OIDC_CLIENT_SECRET=... \
crucible-controller oidc-claims

oidc-claims runs one login and prints every claim that the issuer gives this client. The test passes if the output has a groups array with the expected paths. After the change, GET /api/whoami in a browser reports the login and a list of groups that is not empty.

Offline credentials for scheduled work

A scheduled launch has no session, and a launch that uses a team secret needs the current group membership of the owner. Set CONTROLLER_CREDENTIAL_KEY (or CONTROLLER_CREDENTIAL_KEY_FILE) to a base64 AES-256 key. The callback then seals the offline refresh token of each user with that key, and a schedule uses that credential to refresh the groups of its owner when it fires. Without a key, the controller stores nothing. A schedule then uses the group snapshot from its last save, and it parks when the snapshot is older than CONTROLLER_SCHEDULE_OWNER_TTL_SECS.

Three conditions are necessary. Two of them are on the issuer:

  1. offline_access is in CONTROLLER_OIDC_SCOPES.
  2. The issuer grants offline_access to the client and to the user.
  3. The client allows refresh-token reuse. Two schedules of one owner can fire in one sweep. The controller runs their refreshes one at a time for each subject, but an issuer that revokes on any reuse can revoke the credential when two refreshes overlap.

To rotate the key, put the new key on the first line and keep the old key below it. The first line seals, and every line decrypts. CONTROLLER_SESSION_GROUP_REFRESH_MINUTES sets the maximum age of the groups of a live session before the next request reads them again. If the issuer refuses, the session becomes a viewer until the user signs in again. Users revoke their own credential on the Settings page.

Keycloak

Use a confidential client with the authorization-code flow and a group-membership mapper. The test suite of the controller uses the realm in crucible-controller/tests/keycloak/crucible-realm.json. It imports without changes with start-dev --import-realm. It is the reference for the settings below.

  1. Client crucible-controller: confidential, Standard flow on, valid redirect URI https://<host>/auth/callback (plus the exact CONTROLLER_OIDC_REDIRECT_URL if it differs).
  2. Group mapper on that client: mapper type Group Membership, token claim name groups, full group path on, added to the ID token and the userinfo endpoint. The controller reads groups as paths (/platform-devs). Team rules and CONTROLLER_OPERATOR_GROUPS match against these paths.
  3. Offline access, if schedules run with team secrets: assign the offline_access role of the realm to the users (or to the default roles), and allow refresh-token reuse on the client (Revoke Refresh Token off, or Refresh Token Max Reuse at least 1).

With the reference realm running locally:

docker run -d --name keycloak -p 8180:8080 \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
  -v "$PWD/crucible-controller/tests/keycloak:/opt/keycloak/data/import:ro" \
  quay.io/keycloak/keycloak:26.4 start-dev --import-realm
export CONTROLLER_OIDC_ISSUER=http://127.0.0.1:8180/realms/crucible
export CONTROLLER_OIDC_CLIENT_ID=crucible-controller
export CONTROLLER_OIDC_CLIENT_SECRET=$(jq -r '.clients[] | select(.clientId=="crucible-controller") | .secret' crucible-controller/tests/keycloak/crucible-realm.json)
export CONTROLLER_OIDC_REDIRECT_URL=http://127.0.0.1:8080/auth/callback

alice (password alice-password) is in /platform-devs; mallory (mallory-password) is in /other-team.

Dex

Dex emits groups as a top-level claim when the upstream connector supplies groups, so you do not need a mapper. The controller matches the exact strings that Dex emits. For most connectors, these are bare names (platform-devs), not paths. Use the same names in CONTROLLER_OPERATOR_GROUPS and in team rules.

A static client:

issuer: https://dex.example.com
staticClients:
  - id: crucible-controller
    secret: ...
    name: crucible
    redirectURIs:
      - https://crucible.example.com/auth/callback
oauth2:
  skipApprovalScreen: true
connectors:
  - type: github
    id: github
    name: GitHub
    config:
      clientID: ...
      clientSecret: ...
      redirectURI: https://dex.example.com/callback
      orgs:
        - name: your-org
          teams:
            - platform-devs

Dex issues refresh tokens if the client requests offline_access and the connector supports it (GitHub, LDAP, and OIDC upstreams do). Before you point the controller at Dex, use oidc-claims to check that groups has the expected format.

Without an issuer

A controller without CONTROLLER_OIDC_ISSUER runs no login. It starts in one of two shapes:

  • With CONTROLLER_DEV_IDENTITY, it is an open controller for local development. Every request uses that login, and the controller binds its HTTP surface to loopback. The controller refuses to start if CONTROLLER_API_TOKEN is also set.
  • With CONTROLLER_API_TOKEN, only machine callers get in: the static token, with the login in CONTROLLER_API_TOKEN_IDENTITY if it is set, and API keys.

With neither, the controller refuses to start. A deployment that forgets its issuer does not become open.

Retired settings

The controller refuses to start when one of these is set, and the error names the setting:

SettingUse instead
CONTROLLER_AUTH_MODE=proxy (any value other than native)CONTROLLER_OIDC_ISSUER: the controller runs the login itself.
CONTROLLER_PROXY_TOKENCONTROLLER_OIDC_ISSUER for people, CONTROLLER_API_TOKEN or an API key for machines.
CONTROLLER_KUBE_USER_AUTHAn API key. Cluster tokens are not bearers.
CONTROLLER_OIDC_DEVICE_CLIENT_IDAn API key. Device-flow access tokens are not bearers.
CONTROLLER_TLS_ADDRTLS at the Route. The controller serves plain HTTP.
CONTROLLER_MLFLOW_TRACKING_URI, CONTROLLER_MLFLOW_EXPERIMENT, CONTROLLER_MLFLOW_EXPERIMENT_MAP, CONTROLLER_MLFLOW_TOKEN, CONTROLLER_MLFLOW_SWEEP_SECSGET /api/export/runs.parquet and the run evidence routes. The MLflow run exporter was removed.

API keys

The Settings page creates crk_<id>_<secret> keys. The controller stores only the SHA-256 of the secret, so you cannot recover a lost key. Create a new one. By default, keys expire after 90 days. You can revoke a key on the same page. A key has the login of its owner and the groups from their last browser sign-in. A key never has more access than its owner, and its groups are never newer than the last login. A caller that authenticated with a key cannot create or revoke keys. A key cannot create a replacement key or stay valid after revocation.

Each rejection on the bearer path tells the cause: no header, not a key, a key of a different controller, expired, or revoked. If crux whoami answers credential: bearer (anonymous), the string in the header does not start with crk_.

Cluster identities

MCP servers that check Kubernetes permissions (SubjectAccessReview) need to know who a person is on the cluster they run on. People's access tokens carry it as kube_user, and Settings → Cluster identities lists every cluster the deploy profile knows, the rule that cluster uses, and the caller's username there. The deploy profile declares each cluster's rule:

[cluster]                                 # the hub
identity = { claim = "login" }            # usernames are SSO logins

[clusters.spoke-b]
kubeconfig_secret = "spoke-b-kubeconfig"
identity = { claim = "email" }            # the cluster's OIDC login uses the same email

[clusters.spoke-a]
kubeconfig_secret = "spoke-a-kubeconfig"
identity = "link"                         # the default: each person proves it to the cluster

prefix puts the API server's OIDC username prefix in front of a claim ({ claim = "login", prefix = "sso:" }). A claim cluster shows the resolved name read-only. A link cluster shows Link, Relink and Unlink, and records only what the cluster itself said: the link's username, how it was proven, and when. The token used to prove it is never stored or logged. On the hub, an administrator's override (PUT /api/users/{login}/kube-user, audited, never self-applied) wins over the rule and shows as such; it is also what the person's runs act as there.

Linking through an OpenShift cluster's OAuth server

The controller registers as a confidential OAuthClient on each cluster listed in the profile's [cluster_link] table. Link sends the browser to that cluster's authorize endpoint (read from /.well-known/oauth-authorization-server on its API server) with PKCE and a state bound to the person's session. The callback redeems the code for a user:info token, the cluster's TokenReview names its owner, the username is recorded, and the token is dropped.

[cluster_link]
openshift = ["spoke-a"]
# external_url = "https://crucible.apps.hub.example.com"  # default: CONTROLLER_AGENT_ISSUER
# client_id = "crucible-controller"
# access_token_max_age_secs = 300

The callback sits under the agent issuer, CONTROLLER_AGENT_ISSUER, unless external_url names another URL browsers reach the controller at. Render the client for one cluster, then create it there with a fresh secret, and give the same secret to the controller under the cluster's name:

crucible deploy render --profile profile.toml --cluster-link spoke-a \
  --issuer https://crucible.apps.hub.example.com > oauthclient-spoke-a.yaml
secret=$(openssl rand -hex 32)
oc --context spoke-a apply -f oauthclient-spoke-a.yaml
oc --context spoke-a patch oauthclient crucible-controller --type merge -p "{\"secret\":\"$secret\"}"
oc --context hub -n crucible-system create secret generic crucible-cluster-link \
  --from-literal=spoke-a="$secret" --dry-run=client -o yaml | oc --context hub apply -f -

OAuthClient is cluster-scoped, so creating it needs cluster-admin on that cluster. Mount the crucible-cluster-link Secret into the controller and point CONTROLLER_CLUSTER_LINK_SECRETS at the directory: one file per cluster, named for it. A listed cluster without a file offers the paste fallback only. The controller's own credential on each cluster needs tokenreviews create (system:auth-delegator) to review the token, and the controller reaches the cluster's token endpoint (its oauth-openshift route) directly, not through the cluster's proxy_url.

Pasting a token

A link cluster without OAuth takes a pasted oc whoami -t or an OIDC ID token the cluster's API server accepts. The cluster's TokenReview names the owner; the token is dropped the same way.

RouteWhat it does
GET /api/me/cluster-identitiesEvery cluster's rule and the caller's username.
PUT /api/me/cluster-identities/{cluster}Link by pasted token.
POST /api/me/cluster-identities/{cluster}/linkStart the OAuth link; browser sessions only.
GET /oauth/cluster-link/{cluster}/callbackThe cluster's redirect; lands on /settings?linked= or ?link_error=.
DELETE /api/me/cluster-identities/{cluster}Unlink.
GET /api/oauth/consent?id=The authorization request parked on the session, for the consent page.
POST /api/oauth/consentApprove or deny it; answers the client's redirect URI to navigate to.

API keys can read but never link or unlink.

MCP clients

People sign in with public OAuth clients (no secret, PKCE S256 required). Each client has its own client_id, and every code, approval and refresh token is bound to the client it was issued to: redeeming a code as another client is invalid_grant, and presenting a refresh token as another client is invalid_grant and revokes that approval. Access tokens carry the client in azp. Without configuration, three clients are registered:

client_idNameRedirect URIs it may use
claude-codeClaude Codehttp://localhost:<any port>/callback
pipihttp://127.0.0.1:<any port>/callback
codexCodexhttp://127.0.0.1:<any port>/callback and http://127.0.0.1:<any port>/callback/<server id>

Redirect matching follows RFC 8252 §7.3. A registered http URI on a loopback host matches any port on that same host with the same path, and nothing else: no query, no fragment, no credentials, and the request's URI must already be in canonical form. localhost, 127.0.0.1 and [::1] are three different hosts. A client registered on localhost cannot use 127.0.0.1, and the reverse. Any other registered URI must be https and matches only character for character. http on a host that is not a loopback host is never accepted. Codex adds a 12 character id of the MCP server to a pre-registered client's callback path. A registered path that ends in /* allows that one extra segment (1 to 64 of A-Z a-z 0-9 - _), and the built-in codex client uses it.

To register other clients, or to change the built-in ones, point CONTROLLER_PEOPLE_CLIENTS_FILE at a TOML file. The file replaces the built-in list:

[[clients]]
client_id = "claude-code"
name = "Claude Code"
redirect_uris = ["http://localhost/callback"]

[[clients]]
client_id = "codex"
name = "Codex"
redirect_uris = ["http://127.0.0.1/callback", "http://127.0.0.1/callback/*"]

[[clients]]
client_id = "lab-cli"
name = "Lab CLI"
redirect_uris = ["http://127.0.0.1/oauth/done"]
resources = ["https://crucible-kueue.apps.spoke-a.example.com/mcp"]   # default: every audience

A registered client_id is 1 to 64 of A-Z a-z 0-9 . _ -, so it can never look like a URL. If you remove a client, or narrow its resources, its refreshes are refused from the next refresh.

Self-registered clients

pi 1.x, Codex (registration auto) and Claude Code without --client-id register a client of their own before they sign in (RFC 7591). With people OAuth configured, the controller accepts such registrations at POST /oauth/register and advertises registration_endpoint. CONTROLLER_PEOPLE_DCR=false turns registration off and removes registration_endpoint from the metadata. Registration accepts only native public clients:

  • token_endpoint_auth_method is absent or none. No secret, registration access token or management endpoint is issued;
  • grant_types stay within authorization_code and refresh_token, and response_types within code;
  • every redirect_uris entry is http on localhost, 127.0.0.1 or [::1], with no query, fragment or credentials, and at most 8 entries. At authorize, any port on the registered host with the registered path matches.

The client gets a random dyn-… client_id. Its codes and refresh tokens are bound to that id like any other client's. The name it chose is cleaned and cut to 100 characters, and the consent page and Settings always show it as " (self-registered)". The consent page also shows the host:port the code goes to. A registration that has made no successful token exchange (a code or a refresh) for 30 days stops authorizing and refreshing, and the hourly sweep deletes it with its approvals. Naming it to /oauth/authorize, signed in or not, does not keep it alive. Each source address may register 20 times an hour, and about 10000 registrations live at once (429 and 503 past those limits; concurrent registrations on several replicas can go slightly over the cap). The source is the TCP peer, or the last X-Forwarded-For hop when the peer is in CONTROLLER_PEOPLE_TRUSTED_PROXIES (the networks the cluster router connects from). Without that setting every registration behind a router counts against the router's address.

Client ID Metadata Documents

When CONTROLLER_PEOPLE_CIMD_HOSTS lists hosts, a client_id that is an https:// URL on one of those hosts is resolved as an OAuth Client ID Metadata Document (draft-ietf-oauth-client-id-metadata-document, the MCP 2025-11-25 client registration). The metadata then advertises client_id_metadata_document_supported: true. This is off by default, and every URL client_id is refused when it is off. The controller fetches the document when it is asked to authorize and refuses the request, before any consent, unless all of these are true:

  • the URL is canonical https with a host name (not an IP address) and a path, and has no credentials, query or fragment;
  • the host is on the allowlist, and resolves to a public address, never a loopback, private, link-local or other RFC 6890 special-use address (with HTTPS_PROXY set, the proxy resolves names, so this check moves to the proxy);
  • the answer is 200 (redirects are not followed), JSON, at most 5 KiB, within 5 seconds;
  • its client_id equals the URL exactly, it has a client_name and at least one redirect_uris entry, token_endpoint_auth_method is none, it carries no client_secret, and any grant_types or response_types include authorization_code and code.

Its redirect URIs follow the same rules as registered ones. A loopback URI in a document matches any port, but a document cannot use /*. A valid document is cached for its Cache-Control max-age (minus Age), 5 minutes if it sends none, and 1 hour at most. A document with no-store or no-cache, and any refused document, is never cached. The token endpoint does not fetch anything. It checks that the URL's host is still allowed, and the grant it redeems is bound to that URL. The consent page and Settings show the document's client_name with the URL's host (for example "Codex (chatgpt.com)"), so a document cannot pass for a registered client.

Codex publishes https://chatgpt.com/oauth/codex/client.json and pi publishes https://pi.dev/oauth/client.json. Allowlist chatgpt.com and pi.dev to accept them.

Signing in from each tool

Use the MCP server's URL, a people resource of the deploy profile ([kueue] people, at its Service URL or its people_urls entry). Each tool finds this controller through the server's protected resource metadata.

With self-registration on (the default), no client options are needed:

# Claude Code: then run /mcp in a session and choose Authenticate.
claude mcp add --transport http crucible-kueue https://crucible-kueue.apps.spoke-a.example.com/mcp

# pi
pi mcp add crucible-kueue --url https://crucible-kueue.apps.spoke-a.example.com/mcp
pi mcp login crucible-kueue

# Codex (mcp add starts the sign-in itself; mcp login repeats it)
codex mcp add crucible-kueue --url https://crucible-kueue.apps.spoke-a.example.com/mcp
codex mcp login crucible-kueue

Each tool then signs in as a self-registered client. To sign in under the registered client instead, so that consent and Settings show the verified name, or when CONTROLLER_PEOPLE_DCR=false, pass the registered id: claude mcp add --client-id claude-code …, pi mcp add … --oauth-client-id pi, codex mcp add … --oauth-client-id codex. Without self-registration and without a client id, Codex ends with "Dynamic client registration not supported", and pi ends with "Authorization server does not support dynamic client registration".

If chatgpt.com is on CONTROLLER_PEOPLE_CIMD_HOSTS, Codex in auto mode prefers its metadata document over registering, and signs in as https://chatgpt.com/oauth/codex/client.json. pi signs in under its document when its mcp.json entry has "oauth": {"clientRegistration": "cimd", "callbackUrl": "http://127.0.0.1/callback"} and pi.dev is allowlisted.

pi used to borrow claude-code. Under claude-code, pi's 127.0.0.1 callback is now refused. Re-add the server without OAuth options (or with --oauth-client-id pi) and sign in again. An existing pi approval keeps refreshing as claude-code until it expires or someone signs it out.

GET /oauth/authorize from an MCP client parks the request on the browser session and sends the browser to /consent?id=… in the controller UI. The page names the client (its registered name, a metadata document's client_name with its host, or a self-registered client's chosen name marked "(self-registered)") and shows its client_id, the host:port the code goes to, the server, scopes and login, and the person's username on the server's cluster. A link cluster nobody has linked offers Link (and the paste box where the cluster has no OAuth) right there, and the sign-in lands back on the same page. Allow and Deny are a JSON call that returns the client's redirect URI with a code or an error, and the page navigates to it. A request answers once, and only from the browser and person that started it.

People's tokens and refresh

Access tokens last 15 minutes. Codes and refresh tokens live in Postgres, keyed by the SHA-256 of the secret, so every replica honours them and a restart signs nobody out. A refresh token is spent on use and the next one returned; presenting a spent one again revokes the whole approval. An approval dies after 14 days without a refresh or 90 days after it was granted. Every refresh reads the person again: a deleted account, one whose stored credential the identity provider refused, or a cluster username that was unlinked ends the approval, and a relink shows up in the next token. Settings → MCP clients lists the live approvals by client. Each client has its own Sign out (DELETE /api/me/mcp-sign-ins?client_id=<id>), and one button signs every client out (DELETE /api/me/mcp-sign-ins). Expired codes and approvals are swept hourly.

Vault and the secrets registry

Runs need credentials that belong to the people who launch them: a registry push token, a Jira token, a GitHub App key. The secrets registry delivers these credentials to a run, so nobody has to put them in a pod spec. A user or a team registers a secret in the UI, and the controller keeps the value in Vault. When a launch binds the secret, the controller delivers it to the pod of the run as a Kubernetes Secret that the pod owns (ADR-0028, ADR-0036).

The controller is the only Vault client in the fleet. Spokes do not have a Vault token. They get Kubernetes Secrets that the hub writes for one run and deletes with the run.

What the controller needs from Vault

  • A KV v2 mount, VAULT_MOUNT (default crucible). The registry uses only the crucible/ prefix in the mount. A registered secret is at <mount>/crucible/registry/<owner>/<name>, and every write makes a new version. Other applications can share the mount, because the registry does not use its top level.
  • A login method with a policy over that mount, one of:
VAULT_AUTHVariablesUse
approleVAULT_ROLE_ID (or VAULT_ROLE_ID_FILE), VAULT_SECRET_ID_FILE (or VAULT_SECRET_ID, or VAULT_SECRET_ID_WRAPPED for a response-wrapped id), VAULT_APPROLE_MOUNTAny cluster. The secret id is a mounted Secret.
jwtVAULT_JWT_ROLE, VAULT_JWT_TOKEN_FILE, VAULT_JWT_MOUNTKubernetes workload identity: a projected ServiceAccount token whose audience the Vault JWT role binds. There is nothing to rotate.

Also set VAULT_ADDR, VAULT_CACERT for a private CA, VAULT_NAMESPACE on Vault Enterprise, and VAULT_RENEW_THRESHOLD_SECS (default 300), which sets how early the controller renews the login token.

The controller manages the token. It gets the token on the first call, renews it before its TTL ends, and gets a new token when a renewal fails or Vault denies a call. The controller retries a denied call exactly one time after a new login. Secret values cannot go into logs: tokens, secret ids, and payloads are redacted in Debug and have no Display.

Without VAULT_ADDR, the controller is not a Vault client. The write routes of the registry answer 503, and all other functions work.

Bootstrapping a mount and an AppRole

export VAULT_ADDR=https://vault.example.com VAULT_TOKEN=...
vault secrets enable -path=crucible -version=2 kv
vault auth enable approle
vault policy write crucible-hub - <<'EOF'
path "crucible/data/crucible/*"     { capabilities = ["create", "read", "update", "delete"] }
path "crucible/metadata/crucible/*" { capabilities = ["read", "list", "delete"] }
path "crucible/delete/crucible/*"   { capabilities = ["update"] }
EOF
vault write auth/approle/role/crucible-hub token_policies=crucible-hub \
  token_ttl=20m token_max_ttl=60m secret_id_num_uses=0 secret_id_ttl=0
vault read -field=role_id auth/approle/role/crucible-hub/role-id
vault write -f -field=secret_id auth/approle/role/crucible-hub/secret-id

Mount the secret id as a file and point the controller at it:

export VAULT_AUTH=approle
export VAULT_ROLE_ID=<role_id>
export VAULT_SECRET_ID_FILE=/var/run/secrets/vault-approle/secret-id
export VAULT_MOUNT=crucible

For the JWT method, enable jwt auth with the OIDC discovery URL of the cluster as the issuer. Create a role bound to the ServiceAccount of the controller and to the audience of the projected token. Set VAULT_AUTH=jwt, VAULT_JWT_ROLE=crucible-hub, and VAULT_JWT_TOKEN_FILE to the mount path of the projected token.

A local dev server

vault server -dev is sufficient for development. The end-to-end suite of the controller uses it (crucible-controller/tests/vault_e2e.rs, which creates its own mount, roles, and policies). Run the bootstrap above against docker run -d -p 8200:8200 -e VAULT_DEV_ROOT_TOKEN_ID=root hashicorp/vault:2.0 to get a working registry. Set VAULT_SECRET_ID directly, not as a file.

How a secret moves

  1. Register. A user or a team registers a secret on the Secrets page: a name, a kind (opaque, file, registry_authfile, kubeconfig, inference_api_key), and the value. The value goes directly to Vault as a new version at <mount>/crucible/registry/<owner>/<name>. The ledger keeps the pointer, the kind, the owner, and the audit trail, but not the value. You can also register a secret by reference: a path outside the mount. The controller verifies it one time with the Vault token of the person who registers it. It uses that token for one read and then discards it.
  2. Bind. A binding attaches a secret to a scope (a playbook, a schedule) with a projection: an environment variable or a file. To bind, you need bind on the secret and launch on the scope. The policy set decides both. A draft started from a team's blueprint gets that team's bindings for members of the team, and a placeholder for every other secret the pack declares.
  3. Redeem. When the controller dispatches a bound launch, it reads the current version and writes a Kubernetes Secret in the namespace of the run, owner-referenced to the pod of the run. The pod spec projects it as the binding specifies. When the pod is deleted, Kubernetes deletes the Secret. The redactor of the engine gets every delivered value, so the value never appears in a session log.

The visibility of a secret tells if the agent can see it (agent_visible) or if only the broker process on the loop pod can see it (broker_only, the default). Members of a team can use secrets that the team owns, under the policy of the team. Sharing gives the same access to named users or groups.

Deployment secrets

The credentials of the controller (the GitHub token, the API token, the OIDC client secret) are deployment configuration, not registry entries. Keep them in the same Vault, and sync them into the namespace of the controller as Kubernetes Secrets. A CronJob or an external-secrets operator that reads <mount>/crucible/deploy/<release>/... on a schedule can do this. The controller is not involved.

Agent and people MCP architecture

This document describes the MCP stack that lets agents and people act on Kubernetes clusters through crucible. It covers these parts:

  • the two MCP servers, crucible-kueue and buildit;
  • the shared resource server library, crucible-mcp-auth;
  • the controller as the OAuth issuer;
  • the per-run gateway isolation that makes agent identities trustworthy.

The audience is engineers who maintain or extend this code, and reviewers of changes to it. The text follows ASD-STE100 Simplified Technical English. Code names appear exactly as in the source, in backticks. Code references use path:line from the repository root.

Note: Every part of this stack is off unless you configure it. Section 9 lists what stays inert when a value is absent.

Glossary

Each term has one sense in this document.

TermDefinition
access checkOne SubjectAccessReview that a resource server asks the API server before it acts. The type is AccessCheck (crucible-mcp-auth/src/access.rs).
agent tokenA token that the issuer gives to a supervisor for one sandbox of one run.
allowlist profileThe set of manifest fields that crucible-kueue accepts: strict, tenant, or a TOML file.
approvalThe record that a person approved one MCP client for one resource. It is one row in people_grants, names its client_id, and owns a chain of refresh tokens.
assertionThe JWT-SVID that a supervisor sends to the token endpoint as client_assertion.
audienceThe MCP resource URL that a token is for. The aud claim holds it.
authorization codeA single-use secret that the issuer gives to an MCP client after consent. It lives 60 seconds.
bind recordOne row in run_sandboxes. It connects a sandbox id to the run and the loop pod that created the sandbox.
builder podA pod that buildit creates in the buildit namespace ([buildit] namespace) to build and run one image.
builditThe vendored image build MCP server. It runs as a child process in each loop pod.
client IDThe OAuth client_id of an MCP client. It has one of three forms: a registered name, an https:// metadata document URL, or a dyn- id that the issuer gave. The type is ClientId (crucible-controller/src/agent_auth/people/clients.rs:24).
client registryThe list of registered clients: the built-in claude-code, pi and codex, or the clients in CONTROLLER_PEOPLE_CLIENTS_FILE.
cluster tokenA Kubernetes bearer token of a person, for example the output of oc whoami -t.
consentThe page and API call where a person approves or denies an MCP client.
controllerThe crucible-controller process on the hub. It dispatches runs and is the issuer.
crucible-kueueThe MCP server that plans, submits and controls Kueue workloads on one cluster.
deploy profileThe TOML file that CONTROLLER_DEPLOY_PROFILE names. It describes clusters, namespaces and servers.
gatewayThe OpenShell gateway process that creates sandboxes and supervisors for one run.
gateway idThe OpenShell id of one gateway. It is the loop pod name, hashed when longer than 63 characters. The label openshell.ai/gateway-id carries it.
hubThe cluster where the controller runs. Its dispatch name is hub.
identity ruleThe rule in the deploy profile that tells how the controller learns kube_user on one cluster: link or { claim, prefix }.
isolated profileA deploy profile with a sandbox_namespace, which means the kubernetes driver. Each run then gets a gateway pod of its own (crucible/src/deploy/render/gateway.rs:55).
issuerThe OAuth authorization server inside the controller. CONTROLLER_AGENT_ISSUER is its identifier.
issuer keyThe ES256 private key that signs every token.
JWKSA JSON Web Key Set: the public keys that verify signatures.
kube_userThe Kubernetes username that a token acts as. Every resource server that does access checks reviews this user.
launcherThe person that a run acts for. The login comes from runs.dispatched_by.
linkA record in kube_identity_links that a cluster's TokenReview proved: this person is this username on this cluster.
loop namespaceThe namespace of loop pods ([cluster] loop_namespace).
loop podThe pod that runs the engine for one run. The controller calls it the run pod. Pack code runs here.
MCP clientA program that a person uses to call MCP servers, for example Claude Code, pi or Codex.
metadata document clientA client whose client_id is the https:// URL of its OAuth Client ID Metadata Document (CIMD), on a host in CONTROLLER_PEOPLE_CIMD_HOSTS.
overrideA hub username that a platform administrator sets for a person. It wins over the identity rule on the hub only.
people tokenA token that the issuer gives to an MCP client for one person.
plan hashThe SHA-256 of the normalized manifest. workload_submit requires it.
pod tokenThe per-run bearer (CRUCIBLE_POD_TOKEN) that a loop pod uses to talk to the controller.
push-only clusterA spoke for which the hub holds a push token and no API client.
refresh tokenA long-lived secret that an MCP client exchanges for a new token. Each use spends it.
registered clientA client in the client registry. The operator vouches for its name and its redirect URIs.
resource serverAn MCP server that verifies tokens with crucible-mcp-auth.
runOne dispatch of a playbook launch or an issue. One loop pod executes it.
sandboxAn OpenShell sandbox: an isolated pod where the agent runs.
sandbox namespaceThe namespace of sandboxes, supervisors and gateway pods ([cluster] sandbox_namespace).
scopeAn OAuth permission name, for example kueue.submit. The scope claim holds a space-separated list.
self-registered clientA client that registered itself at POST /oauth/register (RFC 7591). It has a dyn- client ID and only loopback redirect URIs. Nobody vouches for its name.
SPIFFE IDA URI that names a workload, for example spiffe://<trust domain>/openshell/gateway/<gateway id>/sandbox/<sandbox id>.
SPIREThe SPIFFE runtime that gives JWT-SVIDs to workloads on one cluster.
spokeA cluster other than the hub that the controller dispatches runs to. This document uses spoke-a and spoke-b.
supervisorThe OpenShell process in the pod of a sandbox. It controls egress and injects credentials.
tokenAn OAuth access token that the issuer signs. Other secrets are always named in full: refresh token, pod token, cluster token.
token grantThe OpenShell provider credential type that tells a supervisor to get a token from a token endpoint with its JWT-SVID.
tool policyOptional CEL validate and mutate rules over tool arguments (crucible-mcp-auth/src/policy.rs).
turn podA short pod for a grounded-rank or scope turn. Under an isolated profile it also gets a gateway pod.
workload namespaceA namespace that [kueue] workload_namespaces names. The first is the default namespace of agent workloads.

1. Overview

1.1 The problem

Agents in sandboxes must start cluster work: Jobs and RayJobs that train models, Deployments that serve models, and image builds. A Kubernetes credential must not enter the sandbox. Pack code in the loop pod must not choose who the agent is, or which namespace and queue it uses. People who use MCP clients on their laptops need the same tools, with their own Kubernetes permissions.

1.2 The shape

The controller is the one OAuth issuer for agents and for people. A sandbox supervisor proves its identity with a JWT-SVID from the SPIRE of its cluster. The controller finds the run that the sandbox belongs to and signs a short token. The token names the launcher (sub), the sandbox (act.sub), the run and the launcher's Kubernetes username (kube_user). A person gets a token through the authorization code flow with PKCE, after upstream SSO and consent. The MCP client is a public client: a registered client, a metadata document client, or a self-registered client.

The MCP servers verify each token against the issuer JWKS. Before each action, they ask the API server a SubjectAccessReview as kube_user. So the person's own RBAC is the ceiling. Each run's gateway runs in a pod of its own in the sandbox namespace. So pack code cannot create a supervisor with the SPIFFE ID of another run.

examples/optimize-loop is a worked playbook on these servers: the agent builds with buildit, benchmarks as Kueue Jobs through crucible-kueue and iterates, and a deterministic verifier checks its report against crucible-kueue's replies and the push registry (see the example).

1.3 Components and trust boundaries

flowchart LR
    subgraph Laptop["Person's machine"]
        MC["MCP client<br/>registered, metadata document<br/>or self-registered"]
        BR["Browser"]
    end
    SSO["Upstream SSO<br/>OIDC provider"]
    CIMD["Client metadata host<br/>(allowlisted only)"]
    subgraph HUB["hub cluster"]
        subgraph CTLNS["controller namespace"]
            CTL["controller<br/>issuer, /oauth/token,<br/>/.well-known/jwks.json"]
            PG[("Postgres")]
            KEY["issuer key Secret"]
        end
    end
    subgraph SPOKE["spoke-a cluster"]
        API["API server<br/>Service proxy,<br/>TokenReview, SAR"]
        subgraph SPIRENS["SPIRE namespace"]
            SPIRE["SPIRE server and agents"]
            OIDC["SPIRE OIDC discovery<br/>provider /keys"]
        end
        subgraph LOOPNS["loop namespace (pack code)"]
            LOOP["loop pod<br/>engine, broker,<br/>buildit :8848"]
        end
        subgraph BUILDNS["buildit namespace"]
            BUILDER["builder pods"]
        end
        subgraph SBXNS["sandbox namespace (gateway SA only)"]
            GW["gateway pod<br/>crucible openshell-gateway"]
            SUP["supervisor pod<br/>sandbox"]
        end
        subgraph KNS["crucible-kueue namespace"]
            KQ["crucible-kueue :8850"]
        end
        subgraph WNS["workload namespace"]
            WL["Jobs, RayJobs,<br/>Deployments, ..."]
        end
    end
    MC -- "people token" --> KQ
    MC -- "register, authorize, token" --> CTL
    BR -- "login" --> SSO
    BR -- "consent" --> CTL
    CTL --- PG
    CTL --- KEY
    CTL -- "GET metadata document" --> CIMD
    CTL -- "kube client: Sandbox reads,<br/>gateway objects, /keys proxy" --> API
    API --> OIDC
    LOOP -- "mTLS" --> GW
    GW -- "creates" --> SUP
    SUP -- "JWT-SVID" --- SPIRE
    SUP -- "assertion" --> CTL
    SUP -- "agent token" --> KQ
    SUP -- "agent token" --> LOOP
    LOOP -- "bind sandbox,<br/>pod token" --> CTL
    LOOP --> BUILDER
    KQ -- "SAR as kube_user,<br/>then acts as own SA" --> API
    KQ --> WL

The diagram shows one spoke. The hub can also host runs; it then has the same namespaces. The trust boundaries are the namespaces:

  • The controller namespace holds the issuer key. The controller refuses to start when this namespace is also the loop, rig, sandbox, crucible-kueue or workload namespace (crucible-controller/src/agent_auth/placement.rs:74).
  • The loop namespace runs pack code. The loop service account has no verbs in the sandbox namespace under an isolated profile.
  • The sandbox namespace allows pod creation only to crucible-openshell-gateway. A SPIFFE ID there is as trustworthy as that rule.
  • The crucible-kueue namespace holds the server service account. Workload verbs of that account apply only in the workload namespace.
  • The person's machine holds only a token and a refresh token, never a cluster credential.
  • A client metadata host is outside every boundary. The controller fetches a JSON document from it only when its host is in CONTROLLER_PEOPLE_CIMD_HOSTS. The document can name a client and its redirect URIs, and nothing more.

2. Trust model and identities

2.1 Principals

PrincipalProves itself withShown in tokens as
PersonAn upstream SSO login to the controller. The browser session holds it.sub (the login) and kube_user
RunThe pod token of its loop pod, at the bind endpoint onlyrun_id (the loop pod name)
Sandbox supervisorA JWT-SVID from the SPIRE of its clusteract.sub (the SPIFFE ID), sandbox, workdir

A run acts for its launcher. The launcher is runs.dispatched_by, which must be user:<login>. The controller stamps it when it creates the run row (crucible-controller/src/runs/store.rs:334). The value is the person who last asked for this dispatch: an unpark, a redispatch, a scope, or a paired GitHub approval. Without such a request, the value is the owner of the run. A run that has no person behind it gets no token (GrantError::NoLauncher).

2.2 Token types and claims

All tokens are compact JWS, algorithm ES256, with kid in the header. The issuer signs both kinds with the same key and the same iss.

ClaimAgent tokenPeople tokenContent
issyesyesCONTROLLER_AGENT_ISSUER, an http(s) origin without a path
sublauncher loginperson loginOn whose behalf the call is made
audyesyesOne MCP resource URL. The issuer writes a string; a resource server also accepts an array and passes the token when any entry names one of its --resource URLs
iat, nbf, expyesyesIssue time, start, expiry (Unix seconds)
jtiyesyes16 random bytes, hex
act{"sub": "<SPIFFE ID>"}absentThe presenter: the sandbox supervisor
azpabsentthe client IDThe OAuth client, for example claude-code
scopeyesyesSpace-separated scopes
run_idloop pod nameabsentThe run. It is the pod name because a run id can be longer than a label value
sandboxwhen knownabsentThe sandbox name from its Sandbox resource
workdirwhen boundabsentThe workspace path inside the sandbox
kube_userwhen knownalwaysThe Kubernetes username on the cluster of the audience
kube_clusterthe run's clusteryesThe cluster the token acts on. A server started with --cluster refuses a token naming another, so a token for one cluster's URL does not work at the same URL on another
namespacewhen the audience names namespacesabsentThe audience's first namespace, the default for its server
namespaceswhen the audience names namespacesabsentEvery namespace of the audience. A server that reads it confines the caller to these namespaces, within those it serves

The agent token is built in crucible-controller/src/agent_auth/issuer.rs:820. The people token is built in crucible-controller/src/agent_auth/people.rs:299.

Note: Claims name identity only. There is no role and no queue list in a token. Each resource server decides what the caller may do. crucible-kueue ignores role and queues claims if a token carries them.

2.3 Issuer keys and JWKS

The issuer key is a PKCS#8 P-256 key. The kid is the RFC 7638 thumbprint of its public half, so a new key gets a new kid with no configuration (issuer.rs:152). The JWKS at /.well-known/jwks.json publishes the current key and, when configured, the previous key (issuer.rs:820). The previous key verifies only. It signs nothing.

To rotate the issuer key, do these steps:

  1. Move the current key file to CONTROLLER_AGENT_PREVIOUS_SIGNING_KEY_FILE.
  2. Mount a new key at CONTROLLER_AGENT_SIGNING_KEY_FILE.
  3. Restart every controller replica.
  4. Wait for one token lifetime. The default is 900 seconds; people tokens can live up to 3600.
  5. Remove the previous key variable and restart again.

Warning: All replicas must mount the same keys. A replica with another key signs tokens that the other replicas do not publish.

Resource servers cache the JWKS for 600 seconds. A token with an unknown kid causes a refetch, but not more than one fetch each 30 seconds (crucible-mcp-auth/src/jwks.rs:152). A failed fetch keeps the cached keys. The client refuses a key set larger than 256 KiB and follows no redirects.

2.4 Lifetimes

ItemLifetimeWhere set
Agent token900 to 1800 seconds, default 900CONTROLLER_AGENT_TOKEN_TTL_SECS
People token1 to 3600 seconds, default 900CONTROLLER_PEOPLE_ACCESS_TTL_SECS
Authorization code60 seconds, single useCODE_TTL_SECS
Refresh token chaindies 14 days after the last refresh, or 90 days after approvalREFRESH_IDLE_SECS, REFRESH_ABSOLUTE_SECS
Self-registered clientdies 30 days after its last successful token exchange (a code or a refresh), with its approvals; naming it to /oauth/authorize does not countDcrPolicy::idle
Registration rate window20 registrations for each source address in each hourDcrPolicy::per_source, DcrPolicy::window
Metadata document cachethe Cache-Control max-age minus Age, 300 seconds without max-age, at most 3600 secondsCimdLimits
JWT-SVID assertionat most 900 seconds from iat to expMAX_SVID_LIFETIME_SECS
Cluster link flow10 minutes from start to callbackFLOW_TTL
Cluster OAuth token for a link300 seconds by default, then dropped after one review[cluster_link] access_token_max_age_secs
Gateway certificatesrun deadline in days, rounded up, plus 1 day; 30 days for a run without a deadlinevalidity_days
JWKS cache600 seconds, refetch at most each 30 secondsJwks::from_source
Allowed SAR decision cache30 seconds; denials are not cachedALLOW_TTL
OpenShift group cache15 seconds, so a removed group stops allowing within 45 seconds together with the allow cacheGROUPS_TTL

2.5 What each component verifies

The controller token endpoint verifies the assertion. Section 3 gives the full list.

Every resource server verifies a token through ResourceServer::validate (crucible-mcp-auth/src/server.rs:201) and Verifier::verify (crucible-mcp-auth/src/verify.rs:65):

  1. The header algorithm is asymmetric (RS, PS, ES or EdDSA). HS* and none never verify.
  2. The header has a kid, and the JWKS of the issuer has that key. Under an issuer trusted for runs (the controller), the header typ is at+jwt (RFC 9068 §2.1), for a run's token and a person's alike. The controller types every access token it mints, so an ID token, an assertion or any other JWT under its iss and key is refused. An issuer trusted only for people, such as an OIDC realm, is not held to it.
  3. The algorithm of the key, when the JWK declares one, is the header algorithm.
  4. The signature is correct.
  5. iss is exactly a trusted issuer string. There is no URL normalization.
  6. aud names at least one configured resource URL.
  7. exp and nbf hold, with 30 seconds of leeway.
  8. exp, iss, aud and sub are present.
  9. The token has every scope in required_scopes (kueue.read or buildit.read).

Note: the controller started typing its access tokens and the servers started requiring it in the same change. A live hub and its spokes need the controller and crucible-kueue/buildit rolled together: servers built with the check refuse an older controller's untyped tokens. An older server ignores typ, so rolling the controller first is safe.

The configuration of each trusted issuer says whom its tokens stand for: runs or people (Callers). When one issuer is trusted for both, a token with act is a run token and a token without act is a people token. The token cannot choose its kind in any other way.

Then each server applies its own rules:

Checkcrucible-kueuebuildit
Caller kindRuns and people. People only with an access reviewer (--kube-authz)Runs only
kube_userRequired for both kindsNot read
run_idRequired for runs. A people token with run_id is refusedMust equal --run-id
sandboxNot readRequired, a label value
workdirNot readOptional absolute normal path
Per-call scopekueue.read or kueue.submit per tool (crucible-kueue/src/access.rs:52)buildit.read or buildit.build per tool (buildit/src/mcp.rs:1443)
Access checksSubjectAccessReview per callNone
Unlisted tool or methodRefusedRefused

2.6 Why kube_user RBAC is the ceiling

crucible-kueue acts with its own service account. That account can create workloads in the workload namespace. Before each call, the guard turns the call into access checks. For each check it asks the API server a SubjectAccessReview as kube_user, with the groups of that user (crucible-mcp-auth/src/kube.rs:209). The server does the call only when every review allows it. So:

  • A token can do at most what its kube_user can do with kubectl. The token scopes, the tool policy, the allowlist profile and the server account reach can only reduce that.
  • An agent token acts as its launcher. A run cannot do more than the person who launched it.
  • The issuer and the reviewer both refuse a system: username (crucible-controller/src/identity/kube_identity.rs:27, crucible-mcp-auth/src/kube.rs:67). So no token can act as a node, a service account or the cluster. Groups from a token claim also lose every system: entry.
  • The server creates pods with its own identity. So the caller must be able to get each Secret and PersistentVolumeClaim that a manifest names (crucible-kueue/src/access.rs:149).
  • A new RoleBinding applies on the next call, because denials are not cached. A removed permission can still allow calls for up to 30 seconds, because allowed decisions are cached.
  • workload_status and tasks/get read the workload's Kueue Workloads, its pods and their events with the server's account, and show each only when kube_user may get and list it in the namespace. A part the caller may not read is left out, and the reply's omitted names it and why (crucible-kueue/src/access.rs, StatusPart).
  • Any caller who may use a queue can discover its hardware. This is a guarantee, and a deliberate exception to the ceiling: hardware_describe and queue_describe check only get or the submit verb (use) on the LocalQueue, and hardware_describe reads the nodes behind the queue's flavors with the server's account, so an agent can pick a flavor and a GPU product without RBAC on nodes. It returns each flavor's node labels and taints, and for the nodes those select, how many there are and how many are schedulable per GPU product, and their accelerator capacity. Node names are not returned. Tests hold the guarantee (crucible-kueue/tests/http_auth.rs, and cluster_e2e.rs against a cluster).

The issuer never decides what a person may do. It decides only who the person is on each cluster (section 5).

3. Agent token flow

3.1 From dispatch to the first MCP call

sequenceDiagram
    autonumber
    participant C as controller (hub)
    participant A as API server (spoke-a)
    participant L as loop pod (engine)
    participant G as gateway pod
    participant S as supervisor
    participant P as SPIRE agent
    participant K as crucible-kueue
    C->>A: create gateway Service in sandbox namespace
    A-->>C: ClusterIP
    Note over C: mint run CA, server cert for the ClusterIP,<br/>loop and supervisor client certs, drop CA key
    Note over C: run Secret gets loop client TLS, gateway URL,<br/>CRUCIBLE_AGENT_TOKEN_ENDPOINT, CRUCIBLE_AGENT_SVID_AUDIENCE,<br/>CRUCIBLE_AGENT_SERVERS, run_agent_tools row written
    C->>A: loop Service, NetworkPolicy, gateway pod, TLS Secrets owned by the gateway pod
    C->>A: create loop pod
    L->>G: health check over mTLS, register the CLI
    L->>G: import token-grant provider profiles (crucible-kueue, buildit)
    L->>G: CreateSandbox with those providers
    G->>A: create Sandbox and supervisor pod,<br/>label openshell.ai/gateway-id, annotation openshell.ai/sandbox-id
    G-->>L: sandbox id
    L->>C: POST /api/sandbox-bindings/runs/{run}<br/>Bearer pod token, {sandbox_id, workdir}
    C->>A: list Sandbox by sandbox-id and gateway-id labels
    A-->>C: the Sandbox and its name
    Note over C: write bind record, owner-reference the Sandbox to the gateway pod
    C-->>L: 204
    S->>P: fetch JWT-SVID, audience = issuer
    P-->>S: JWT-SVID
    S->>C: POST /oauth/token client_credentials,<br/>client_assertion = JWT-SVID, audience = kueue URL, scope
    C->>A: GET SPIRE /keys through the Service proxy (cached)
    A-->>C: SPIRE JWKS
    Note over C: verify the assertion, read gateway id and sandbox id,<br/>find the bind record, check cluster and gateway,<br/>resolve the launcher and kube_user
    C-->>S: access_token, expires_in, scope
    Note over S: inject Authorization on egress<br/>to the kueue host and port only
    S->>K: MCP request with Bearer token
    K->>A: SubjectAccessReview as kube_user
    K->>A: act with the server service account

The steps are these:

  1. Dispatch reserves the gateway. The Service comes first because its ClusterIP goes into the server certificate (crucible-controller/src/runs/workpod/gateway.rs:184).
  2. The controller folds the loop pod client material and the agent server access into the run Secret before it renders the loop pod (crucible-controller/src/runs/workpod/run.rs:813, access in crucible-controller/src/runs/workpod/agent_servers.rs). A server is granted only where the issuer can mint its tokens: it trusts the run's cluster's SPIRE, discovered or named in CONTROLLER_AGENT_SPIRE (crucible-controller/src/runs/workpod/agent_servers.rs, granted). The loop pod gets them as one CRUCIBLE_AGENT_SERVERS list. The run records the servers that it got, with the scopes of the audience.
  3. The gateway starts before the loop pod, so it boots while the loop pod schedules (gateway.rs:256).
  4. The engine reads CRUCIBLE_OPENSHELL_GATEWAY_URL and talks to the remote gateway over mTLS (crucible/src/openshell/gateway.rs:422-437). It creates one token-grant provider, one egress entry and one .mcp.json entry for each server in CRUCIBLE_AGENT_SERVERS the same way, after starting a server whose transport is the loop pod (crucible/src/openshell/agent_servers.rs). A provider holds no secret and gives the sandbox no placeholder (crucible/src/openshell/provider.rs:368).
  5. After CreateSandbox, the engine binds the sandbox (crucible/src/openshell/run.rs:469). It binds only when the loop pod carries CRUCIBLE_AGENT_TOKEN_ENDPOINT. A failed bind does not fail the turn. The sandbox then gets no token.
  6. The bind route trusts only the pod token of that run (crucible-controller/src/agent_auth/bindings.rs:298). It reads the sandbox name from the cluster, never from the request (crucible-controller/src/agent_auth/directory.rs:60).
  7. The supervisor asks SPIRE for a JWT-SVID with the audience in token_grant.jwt_svid_audience. That value is the issuer identifier.
  8. The token request has exactly the fields that OpenShell sends: grant_type, client_assertion_type, client_assertion, audience, scope. Any other field, for example client_id or resource, is refused (crucible-controller/src/agent_auth/mod.rs:149).
  9. The supervisor injects the token as a bearer header only on egress to the host and port of the provider endpoint, from a binary the provider's profile lists. The profile lists the same binaries as the sandbox policy: OpenShell injects a grant only when the profile's own rule admits the request, and with binary identity required, a rule admits only the binaries it names. The agent never sees the token.

3.2 What the token endpoint checks

issue (crucible-controller/src/agent_auth/mod.rs:314) does these checks in this order:

  1. The issuer mints agent tokens: the profile isolates gateways and describes a server, or CONTROLLER_AGENT_SPIRE names a cluster.
  2. The deploy profile is isolated and names a sandbox namespace (crucible-controller/src/agent_auth/placement.rs:48).
  3. grant_type is client_credentials.
  4. client_assertion_type is urn:ietf:params:oauth:client-assertion-type:jwt-bearer.
  5. The assertion iss is the SVID issuer of exactly one configured cluster. That cluster's SPIRE keys verify the assertion (crucible-controller/src/agent_auth/issuer.rs:701).
  6. The assertion aud is exactly one value, the issuer identifier.
  7. The assertion has a numeric iat, not more than 30 seconds in the future.
  8. exp minus iat is not more than 900 seconds.
  9. sub is <gateway prefix><gateway id>/sandbox/<sandbox id> for the prefix of that cluster.
  10. A bind record exists for the sandbox id, and its loop pod is in the running state.
  11. The cluster of the run is the cluster whose SPIRE signed the assertion.
  12. The requested audience is the URL of a server the deploy profile describes on the run's cluster (Audience::of).
  13. The gateway id in the SPIFFE ID is the gateway id of the bound loop pod.
  14. The run has a launcher (dispatched_by is user:<login>).
  15. Each requested scope is in the audience entry. A request with no scope gets all the scopes of the entry.
  16. When the audience requires a kube_user (the profile describes its server as one that authorizes as kube_user, as [kueue] does), it is known for the launcher on the run's cluster. For other audiences, the token omits an unknown kube_user, and the controller logs a warning. Every audience is described: at startup the controller refuses one for which the profile describes no server at that URL on that cluster, comparing URLs once parsed (Audience::described_by, crucible-controller/src/agent_auth/issuer.rs).

Note: Pack code holds the run CA's client certificate, so it can make its gateway create sandboxes and request a token for any audience on its run's cluster, with that audience's scopes. The audience allowlist, per cluster, is the boundary: list only the servers a run on that cluster may use, with the scopes it may hold.

The controller reads SPIRE keys through the API server Service proxy of each cluster, with the same client it dispatches with (crucible-controller/src/agent_auth/spire.rs:21). The path is /api/v1/namespaces/<ns>/services/<scheme>:<service>:<port>/proxy/keys (issuer.rs:189). The scheme is https unless the entry starts with http://.

3.3 Refusals

Every refusal returns only the OAuth error code and a fixed description. The detail goes to the controller log as agent token refused with reason and the user_agent.

CaseErrorHTTP
No agent clusters (a people-only issuer)unsupported_grant_type400
Profile not isolated, or no sandbox namespaceunauthorized_client400
Malformed form, repeated field, unknown fieldinvalid_request400
Wrong assertion type, unknown SPIRE iss, bad signature, wrong aud, no iat, future iatinvalid_client401
Assertion lifetime over 900 secondsinvalid_client401
SPIFFE ID not under the gateway prefix of its clusterinvalid_client401
SPIRE keys could not be fetchedserver_error500
No audience, or an audience not in the allowlistinvalid_target400
Sandbox never bound (unbound sandbox)invalid_grant400
Bound loop pod not in runninginvalid_grant400
SVID from the SPIRE of another cluster than the run (wrong cluster)invalid_grant400
SVID gateway id is not the gateway of the bound run (the bind record of another run)invalid_grant400
Run dispatched for no personinvalid_grant400
Scope not listed for the audienceinvalid_scope400
kueue.* scope and no kube_user for the launcherinvalid_grant400

The bind route has its own refusals (bindings.rs:320):

CaseHTTP
No pod token, or a token that does not verify401
A pod token of another run403
Unknown run, or a run with no work-pod row404
Loop pod not in running409
Profile names no sandbox namespace409
No Sandbox with that id that this run's gateway created404
The cluster could not be asked503
Two Sandbox resources with that id, or one with no name409
The sandbox id is bound to another loop pod, or its name is bound by another live pod on the same cluster409

Note: Pack code holds the gateway client certificate of its own run. It can create sandboxes for its own run with the driver_config fields that the gateway accepts. Those sandboxes carry the gateway id of its own run, so the issuer refuses them for any other run.

4. People token flow

4.1 MCP clients

People sign in with public OAuth clients. A public client has no secret, so PKCE S256 is required. Each code, approval and refresh token is bound to the client ID that it was issued to. The people token names that client in azp.

4.1.1 Client ID forms

ClientId::parse (crucible-controller/src/agent_auth/people/clients.rs:133) puts each client_id in one of three forms. The form decides who vouches for the client.

FormShapeKindWho vouchesResolved by
Registered1 to 64 of A-Z a-z 0-9 . _ -, not starting with dyn- (clients.rs:98)registeredThe operatorThe client registry
MetadataA canonical https:// URL with a host name and a path, and no credentials, query or fragment (clients.rs:64)metadataThe host that publishes the documentA fetch of the document (section 4.1.3)
Dynamicdyn- and 16 to 64 base64url characters (clients.rs:114)dynamicNobodyA row in people_dynamic_clients (section 4.1.4)

Clients (crucible-controller/src/agent_auth/people/registry.rs:27) holds the registry, the metadata document resolver and the registrations. It has two entry points:

  • resolve (registry.rs:66) serves /oauth/authorize. It returns the client with its name and redirect rules. For a metadata document client, it fetches the document.
  • admits (registry.rs:87) serves /oauth/token. It fetches nothing. It refuses a registered client that the registry no longer lists, a metadata document client whose host is no longer allowlisted, and a registration that is no longer live. The grant that the request redeems is already bound to its client ID.
flowchart TD
    REQ["client_id from the request"] --> P{"ClientId::parse"}
    P -- "fails" --> BAD["refuse: error page<br/>or invalid_client"]
    P -- "https:// URL" --> MD{"CONTROLLER_PEOPLE_CIMD_HOSTS<br/>set and lists the host?"}
    MD -- no --> BAD
    MD -- yes --> FETCH["authorize: fetch or cache hit<br/>token: host check only"]
    FETCH --> DOC{"document valid?"}
    DOC -- no --> BAD
    DOC -- yes --> OK["Client: id, name, redirect rules"]
    P -- "dyn- id" --> DYN{"people_dynamic_clients row,<br/>used in the last 30 days?"}
    DYN -- no --> BAD
    DYN -- yes --> STAMP["stamp last_used_at"] --> OK
    P -- "registered name" --> REG{"in the client registry?"}
    REG -- no --> BAD
    REG -- yes --> OK

4.1.2 The client registry

Without configuration, the registry has three clients (clients.rs:448):

client_idNameRedirect URIs
claude-codeClaude Codehttp://localhost:<any port>/callback
pipihttp://127.0.0.1:<any port>/callback
codexCodexhttp://127.0.0.1:<any port>/callback and http://127.0.0.1:<any port>/callback/<server id>

CONTROLLER_PEOPLE_CLIENTS_FILE names a TOML file of [[clients]] tables. Each table has client_id, name, redirect_uris and an optional resources list. The file replaces the built-in list, so list the built-in clients too if you want to keep them (clients.rs:479). The controller does not start when the file is malformed, when it lists no client or one client twice, or when a resources entry is not a people resource (crucible-controller/src/agent_auth/people.rs:172). Without resources, a client can get tokens for every audience.

[[clients]]
client_id = "lab-cli"
name = "Lab CLI"
redirect_uris = ["http://127.0.0.1/oauth/done"]
resources = ["https://crucible-kueue.apps.spoke-a.example.com/mcp"]

When you remove a client, its next refresh gets invalid_client. When you remove a resource from its resources, its next refresh for that resource revokes the approval.

Redirect matching follows RFC 8252 §7.3 (RedirectRule, clients.rs:237):

  • A registered http URI on localhost, 127.0.0.1 or [::1] matches any explicit port on the same host, with the same path. It matches no query, no fragment and no credentials.
  • localhost, 127.0.0.1 and [::1] are three different hosts. A client registered on localhost cannot use 127.0.0.1.
  • A registered path that ends in /* also matches one more segment of 1 to 64 base64url characters. Codex adds such a server id to the callback path. Only the registry can use /*.
  • Any other registered URI must be https. It matches only character for character.
  • The requested URI must already be in canonical form (Client::redirect, clients.rs:348).

4.1.3 Client ID Metadata Documents

CONTROLLER_PEOPLE_CIMD_HOSTS is a comma-separated list of host names. When it is set, a client_id that is an https:// URL on one of those hosts is resolved as an OAuth Client ID Metadata Document (draft-ietf-oauth-client-id-metadata-document). When it is unset, every URL client_id is refused (ClientError::MetadataOff).

At /oauth/authorize, Cimd::client (crucible-controller/src/agent_auth/people/cimd.rs:370) returns a cached document or fetches it, and only for a signed-in browser: a request naming a metadata document without a session goes to /auth/login first, so nobody can make the controller fetch without signing in. The fetch (cimd.rs:390) has these rules:

  • The host must be on the allowlist (cimd.rs:362).
  • The resolver gives only public addresses. It drops loopback, private, link-local and other RFC 6890 special-use addresses (cimd.rs:127, cimd.rs:170). With HTTPS_PROXY set, the proxy resolves the names, so this check moves to the proxy.
  • The client uses only https and follows no redirects (cimd.rs:338).
  • The answer must be 200, JSON, at most 5 KiB, and arrive within 5 seconds.

The document must describe a public client of its own URL (client_of, cimd.rs:200):

  • client_id is the URL exactly.
  • client_name is 1 to 100 printable characters.
  • redirect_uris has at least one entry. The entries follow the registry rules, but /* is not accepted.
  • token_endpoint_auth_method is none. There is no client_secret and no client_secret_expires_at.
  • grant_types and response_types, when present, include authorization_code and code.

A valid document is cached for its Cache-Control max-age minus Age. Without max-age it is cached for 300 seconds. The limit is 3600 seconds (freshness, cimd.rs:266). A document with no-store or no-cache is not cached. A refused document is not cached.

The consent page and Settings show the client_name of the document with the host of the URL, so a document cannot look like a registered client. Codex publishes https://chatgpt.com/oauth/codex/client.json. pi publishes https://pi.dev/oauth/client.json.

4.1.4 Self-registered clients

pi 1.x, Codex and Claude Code without a client id register a client before they sign in (RFC 7591). The controller accepts these registrations at POST /oauth/register (crucible-controller/src/agent_auth/people/dynamic.rs:422). Registration is on by default when the deploy profile serves people. CONTROLLER_PEOPLE_DCR=false turns it off.

sequenceDiagram
    autonumber
    participant M as MCP client
    participant C as controller (any replica)
    participant D as Postgres
    M->>C: GET /.well-known/oauth-authorization-server
    C-->>M: registration_endpoint = issuer/oauth/register
    M->>C: POST /oauth/register {client_name, redirect_uris, ...}
    Note over C: check: public client, loopback http redirect URIs,<br/>grant and response types, at most 8 URIs
    Note over C: rate limit: 20 each hour for each source address
    C->>D: INSERT people_dynamic_clients<br/>only while fewer than about 10000 are live
    C-->>M: 201 {client_id: dyn-..., token_endpoint_auth_method: none}
    M->>C: GET /oauth/authorize?client_id=dyn-...
    C->>D: UPDATE last_used_at WHERE client_id AND not idle, RETURNING the client
    Note over C,D: the hourly sweep deletes registrations idle for 30 days<br/>and the approvals of deleted registrations

check (dynamic.rs:194) accepts only native public clients:

  • token_endpoint_auth_method is absent or none. The controller issues no secret, no registration access token and no management endpoint.
  • grant_types includes authorization_code and stays within authorization_code and refresh_token. response_types is code.
  • redirect_uris has 1 to 8 entries, each at most 512 characters. Each entry is http on localhost, 127.0.0.1 or [::1], with no query, fragment or credentials.
  • The controller removes control characters and repeated white space from client_name and keeps at most 100 characters. An empty name becomes Unnamed client.

The client ID is dyn- and 24 random bytes in base64url. The consent page and Settings show the name as " (self-registered)".

Dcr::register (dynamic.rs:287) applies two limits:

  • Each source address can register 20 times in each hour. The limit is in memory, for each replica. Past it, the answer is 429 with temporarily_unavailable.
  • About 10000 registrations used in the last 30 days can exist. Past it, the answer is 503 with temporarily_unavailable. The count and the insert are one statement but not serialized across replicas, so concurrent registrations can go slightly over: the cap is soft.

The source address is the TCP peer. When the peer is in CONTROLLER_PEOPLE_TRUSTED_PROXIES, as the cluster router is, the source is the last X-Forwarded-For hop (source, dynamic.rs:428). Any other peer, an in-cluster caller included, is its own source and cannot pick a bucket.

Dcr::client (dynamic.rs:345) reads a registration and stamps last_used_at in one statement. A registration that is not used for 30 days stops working. The hourly sweep deletes it and the approvals issued to it (Dcr::sweep, dynamic.rs:375).

Registration refusalErrorHTTP
A redirect URI that is not loopback http, or no URI, or more than 8invalid_redirect_uri400
A body that is not JSON, a secret auth method, or other grant or response typesinvalid_client_metadata400
Rate limit of the source addresstemporarily_unavailable429
10000 live registrationstemporarily_unavailable503
People tokens or registration offinvalid_request404

The consent API (crucible-controller/src/identity/api/consent.rs:63) returns these client fields:

FieldContent
client_idThe client ID
client_nameThe registry name, the client_name of the document, or the name that the client registered
client_hostThe host of the metadata document URL; null for other kinds
client_kindregistered, metadata or dynamic
redirect_originThe host:port that the code goes to

GET /api/me/mcp-sign-ins returns the same client_id, client_name, client_host and client_kind for each live approval. The approval stores the name that the person saw on the consent page.

4.2 Discovery, authorization and code exchange

sequenceDiagram
    autonumber
    participant M as MCP client
    participant K as crucible-kueue
    participant C as controller (any replica)
    participant B as browser
    participant U as upstream SSO
    participant O as cluster OAuth server
    participant H as client metadata host
    participant D as Postgres
    M->>K: POST /mcp, no token
    K-->>M: 401, WWW-Authenticate names the resource metadata URL
    M->>K: GET /.well-known/oauth-protected-resource/mcp
    K-->>M: resource, authorization_servers = [issuer], scopes_supported
    M->>C: GET /.well-known/oauth-authorization-server
    C-->>M: authorization_endpoint, token_endpoint, S256, iss parameter,<br/>registration_endpoint, client_id_metadata_document_supported
    opt no client id configured and registration on
        M->>C: POST /oauth/register
        C-->>M: client_id = dyn-...
    end
    M->>B: open /oauth/authorize?client_id, code_challenge, state, resource, redirect_uri=http://127.0.0.1:port/callback
    B->>C: GET /oauth/authorize
    alt metadata document client
        C->>H: GET client_id URL (unless cached)
        H-->>C: client metadata JSON
    else self-registered client
        C->>D: read and stamp people_dynamic_clients row
    end
    Note over C: resolve the client, match redirect_uri
    alt no native browser session
        C-->>B: 303 /auth/login?rd=...
        B->>U: OIDC login with PKCE
        U-->>B: code
        B->>C: /auth/callback, session row in Postgres
        C-->>B: 303 back to /oauth/authorize
    end
    Note over C: validate, park the request on the session
    C-->>B: 303 /consent?id=...
    B->>C: GET /api/oauth/consent?id=...
    C-->>B: client name, kind, host, redirect origin,<br/>resource, scopes, login, identity on the cluster
    opt link cluster with no link
        B->>C: POST /api/me/cluster-identities/{cluster}/link
        C-->>B: authorize_url of the cluster OAuth server
        B->>O: authorize with PKCE, scope user:info
        O-->>B: code
        B->>C: GET /oauth/cluster-link/{cluster}/callback
        C->>O: redeem code
        C->>C: TokenReview on that cluster, write link, drop the cluster token
    end
    B->>C: POST /api/oauth/consent {id, decision: approve}
    C->>D: insert people_codes row (SHA-256 of the code), bound to the client ID
    C-->>B: {redirect_url: redirect URI?code, state, iss}
    B->>M: navigate to the redirect URI
    M->>C: POST /oauth/token grant_type=authorization_code, client_id, code, code_verifier, redirect_uri, resource
    Note over C: admit the client ID (no fetch)
    C->>D: delete people_codes row, returning the grant
    Note over C: check client ID, redirect_uri, PKCE S256,<br/>resource, resolve kube_user again
    C->>D: insert people_grants with client_id and client_name,<br/>and the first people_refresh_tokens row
    C-->>M: access_token (azp = client ID), refresh_token, expires_in, scope
    M->>K: POST /mcp, Bearer token

The steps are these:

  1. crucible-kueue answers a request without a token with 401 and a challenge that names its RFC 9728 metadata. The metadata lists the trusted issuers as authorization_servers (crucible-mcp-auth/src/metadata.rs:42).
  2. The MCP client reads the RFC 8414 metadata of the controller (crucible-controller/src/agent_auth/issuer.rs:831, crucible-controller/src/agent_auth/mod.rs:445). People support adds authorization_endpoint, the authorization_code and refresh_token grants, the code response type, S256, the none client authentication, and authorization_response_iss_parameter_supported. Two fields depend on the client configuration:
    • registration_endpoint is <issuer>/oauth/register while registration is on. It is absent with CONTROLLER_PEOPLE_DCR=false.
    • client_id_metadata_document_supported is true while CONTROLLER_PEOPLE_CIMD_HOSTS is set. It is absent otherwise.
  3. /oauth/authorize (crucible-controller/src/agent_auth/people.rs:554) resolves the client (section 4.1) and matches redirect_uri against its redirect rules. state is required, at most 1024 characters. The method must be S256. resource must be a people resource of the deploy profile (DeployProfile::people_resources), and a registered client with resources must list it (validate, people.rs:373).
  4. An unknown client, a refused metadata document, an expired registration or a bad redirect gives an error page. The controller never redirects to a URI that the client did not register. When the client and the redirect are correct, the controller sends every other error to the redirect URI with error, state and iss.
  5. Without a native session, the controller sends the browser to /auth/login. After the login, the browser returns to /oauth/authorize. A session whose group refresh failed counts as no session.
  6. The request waits on the session under a random id. The consent page shows it only to the same browser and the same person. The page names the client and shows the host:port that the code goes to (section 4.1.5). No page may frame it: every SPA response and the authorize page send X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none'.
  7. The approval resolves kube_user for the cluster of the audience. An unlinked link cluster answers 409. The controller parks the request on the session again, so the page can link and try again.
  8. The code is 32 random bytes, base64url. Postgres stores only its SHA-256. The stored grant names the client ID and the client name.
  9. The token endpoint admits the client ID without a fetch (registry.rs:87). It marks the code spent under a row lock, so a code works once on any replica (redeem_code in crucible-controller/src/agent_auth/people/grants.rs). A code issued to another client ID is invalid_grant (redeem_code, people.rs). The code is spent all the same. The approval the redemption starts is recorded against the code, and a spent code stays a day past its expiry. Presented again by the client it was issued to, it is invalid_grant and revokes that approval, so the refresh token the first redemption issued stops working (OAuth 2.1 §4.1.3, RFC 9700 §4.5.3); a replay that lands before the approval starts stops it from starting. Presented by another client, it is only unknown, so a code someone saw cannot revoke a person's approval.

Note: Claude Code does not step up after a 403. So a person always gets every scope that the audience lists. Requested scopes outside that list are refused; the OIDC scopes openid, profile, email and offline_access are ignored.

4.3 Refresh with rotation and reuse detection

sequenceDiagram
    autonumber
    participant M as MCP client
    participant C as controller (any replica)
    participant D as Postgres
    M->>C: POST /oauth/token grant_type=refresh_token, refresh_token, client_id
    Note over C: admit the client ID (no fetch),<br/>else invalid_client and nothing is spent
    C->>D: UPDATE people_refresh_tokens SET used_at WHERE key = sha256 AND used_at IS NULL
    alt row updated
        C->>D: read people_grants FOR UPDATE
        Note over C: refuse when revoked
        alt client_id is not the client of the approval
            C->>D: revoke the approval and delete its live token
            C-->>M: invalid_grant
        else same client
            Note over C: refuse when idle over 14 days,<br/>or older than 90 days
            alt another resource or a scope beyond the approval
                C->>D: roll back: the token stays unspent
                C-->>M: invalid_target or invalid_grant
            end
            C->>D: issue the next refresh token
            Note over C: client still allowed for the resource,<br/>read the person again: users row, stored<br/>credential not refused, kube_user on the cluster
            alt person still allowed and kube_user known
                C-->>M: new access_token and new refresh_token
            else refused
                C->>D: revoke the approval
                C-->>M: invalid_grant
            end
        end
    else token already spent
        C->>D: revoke the approval and delete its live token
        C-->>M: invalid_grant
    end

The rotation is one conditional UPDATE, so two replicas cannot both spend one refresh token (grants.rs:223). A spent token stays in the table until its approval goes. When a client presents it again, the controller revokes the whole approval (RFC 9700 §4.14.2). A live token that another client ID presents also revokes the approval (grants.rs:279). A refresh that asks for another resource or a wider scope is refused inside the same transaction, before anything commits (refresh_fits, people.rs:765; grants.rs:309), so the token it presented stays live and the client can refresh with it again.

A refresh also checks the client again. A registered client that the registry no longer lists gets invalid_client. A registered client whose resources no longer lists the resource of the approval loses the approval.

Every refresh reads the person again (crucible-controller/src/identity/people_directory.rs:35):

  • A deleted users row revokes the approval.
  • A stored offline credential that the upstream SSO refused (user_credentials.last_error) revokes the approval.
  • An unlinked cluster username revokes the approval. A new link appears in the next token.

Warning: A refresh does not ask the upstream identity provider. A person deactivated there keeps refreshing until something else refuses them: a stored offline credential the SSO then refuses, the 14-day idle limit, or the 90-day cap on an approval (REFRESH_IDLE_SECS, REFRESH_ABSOLUTE_SECS in crucible-controller/src/agent_auth/people/grants.rs). Without a stored credential (CONTROLLER_CREDENTIAL_KEY unset, or a person who never stored one), nothing else does. To cut a person off sooner, delete their users row, which also drops their stored credential and API keys.

Warning: A client that retries a refresh after a lost response presents a spent token. The controller treats that as reuse and revokes the approval. The person must approve the client again.

A background task deletes expired codes and dead approvals each hour (grants.rs:425). With registration on, the same task deletes registrations idle for 30 days and the approvals of deleted registrations. It starts only when people tokens are configured.

4.4 Several replicas

Any replica can do any step of the flow:

  • Browser sessions, the parked authorization request and a started cluster link live in the Postgres session store.
  • Codes, approvals and refresh tokens live in people_codes, people_grants and people_refresh_tokens. Each method is one statement or one transaction.
  • Every replica signs with the same issuer key and publishes the same JWKS.

Leader election (CONTROLLER_LEASE_NAME) applies only to the daemon. The API, the token endpoint and the bind route answer on every replica.

4.5 Settings and revocation

GET /api/me/mcp-sign-ins lists the live approvals of the caller, with the client of each (crucible-controller/src/identity/api/mcp_sign_ins.rs:57). Settings groups them by client.

  • DELETE /api/me/mcp-sign-ins?client_id=<client ID> revokes the approvals of one client and deletes its unused codes (grants.rs:330). A client_id that does not parse gets 400.
  • DELETE /api/me/mcp-sign-ins revokes all approvals of the caller and deletes their unused codes.

An API key cannot revoke approvals (mcp_sign_ins.rs:113).

5. Per-cluster identity

5.1 Identity rules

The deploy profile gives each cluster one identity rule. The hub rule is [cluster] identity. A spoke rule is [clusters.<name>] identity. A cluster without a rule is link (forge/src/fleet.rs:61).

RuleTOMLThe username is
linkidentity = "link"What the cluster's TokenReview said for a cluster token that the person gave
claimidentity = { claim = "login" }The SSO login of the person
claimidentity = { claim = "email" }The email that the SSO login recorded; one the IdP marks email_verified: false is not recorded, as the Kubernetes OIDC authenticator refuses it
claim with prefixidentity = { claim = "login", prefix = "sso:" }The prefix, then the claim, as the API server's --oidc-username-prefix makes it

Use a claim rule only when the cluster API server trusts the same SSO for the same people. Use link in all other cases.

A person proves a link in one of two ways:

  • Paste a cluster token: PUT /api/me/cluster-identities/{cluster} with {"cluster_token": "..."}. The controller sends a TokenReview to that cluster (crucible-controller/src/identity/kube_links.rs:108).
  • Use the OAuth server of an OpenShift cluster: POST /api/me/cluster-identities/{cluster}/link. The controller is a confidential OAuthClient there. It redeems the code with PKCE for a user:info token and sends that token to a TokenReview (crucible-controller/src/identity/cluster_link.rs:204, cluster_link.rs:237).

Either way the controller stores only the username, the source (token-review or openshift-oauth) and the time. It never stores or logs the cluster token. A token that another cluster minted does not authenticate, so it cannot link this cluster. A link to a system: username is refused.

The OAuth link is offered for a cluster only when all of these are true:

  • [cluster_link] openshift lists the cluster.
  • The controller has a client for the cluster.
  • A file with the cluster's name exists in CONTROLLER_CLUSTER_LINK_SECRETS.

The callback is GET /oauth/cluster-link/{cluster}/callback. It checks the state in constant time, the cluster, the person and the 10-minute limit. It then lands on /settings?linked=<cluster> or ?link_error=<code>. The code is a fixed word, so a crafted URL cannot put text on the page.

5.3 The hub override

A platform administrator can set the hub username of a person: PUT /api/users/{login}/kube-user with {"kube_user": "<name>"}, or null to clear it. An administrator cannot set their own. The event log records it on user:<login>. The override applies on the hub only, and it wins over the hub rule and any hub link (crucible-controller/src/identity/cluster_identity.rs:66).

5.4 Resolution

flowchart TD
    START["resolve(person, cluster)"] --> HUB{"cluster is hub and<br/>users.kube_user_override set?"}
    HUB -- yes --> OV["Known: override"]
    HUB -- no --> RULE{"identity rule of the cluster"}
    RULE -- "claim" --> CL{"claim value present<br/>and a valid username?"}
    CL -- yes --> KR["Known: prefix + claim"]
    CL -- no --> UN["Unavailable: reason"]
    RULE -- "link" --> LK{"kube_identity_links row<br/>for (sub, cluster)?"}
    LK -- yes --> KL["Known: link"]
    LK -- no --> NL["Unlinked"]

The same function decides kube_user for every consumer:

ConsumerPersonClusterWhen
People tokenThe person who approvesThe cluster of the people resourceAt code exchange and at each refresh
Agent tokenThe launcher the bound run names (user:<login> from runs.dispatched_by, read once with the binding), so sub and kube_user name the same personThe cluster of the runAt each token request (for_principal)
Agent tools recordThe same run:<id> principalThe cluster of each tool whose server acts as kube_userAfter the launch commits the run row (attribute, crucible-controller/src/runs/agent_tools.rs)
Settings and consent pagesThe callerEach cluster of the profileOn each read

Issuance and the tools record name who acts as an authz::model::Principal, and for_principal (crucible-controller/src/identity/cluster_identity.rs) resolves it: a user: principal through their sign-in, a run: principal through the person it was dispatched for. A group: or team: principal, or a run dispatched for one, has no username of its own and is refused with a typed error (PrincipalError), which the token endpoint answers with invalid_grant. A team's own identity per cluster would be one more arm of for_principal; the tables would not change.

A launcher that never logged in has no users row, so its kube_user is unavailable. Because the agent token resolves at each request, a new link applies to the next token of a run that is already live.

Note: The links, the override and the settings panels are served only when the controller mints agent tokens, people tokens are configured, or the profile has [cluster_link] (crucible-controller/src/api/mod.rs:672, crucible-controller/src/api/state.rs:306). So an agent-only deployment on a link cluster serves the paste link route. A person can link a username there, and the runs of that person then get it.

6. Per-run gateway isolation

6.1 Why each run has its own gateway

A SPIFFE ID of a sandbox is only as trustworthy as the pods that can carry it. SPIRE derives the ID from pod metadata. If pack code can create a pod in the sandbox namespace, it can claim the SPIFFE ID of any sandbox. When the gateway runs inside the loop pod, the loop service account needs pod rights in the sandbox namespace. Pack code runs as that account.

Profile load refuses a loop_namespace that is the sandbox namespace, but loop pods run where the controller's CONTROLLER_POD_NAMESPACE (hub) or a spoke kubeconfig's context namespace puts them. So dispatch checks the namespace each run's loop pod actually lands in, and refuses a run with an isolated gateway whose loop pod would run in the sandbox namespace, in that cluster's SPIRE namespace, or on the hub in the controller's own (check_loop_namespace).

So under an isolated profile, the controller starts each run's gateway in a pod of its own, as crucible-openshell-gateway. That account is the only identity with sandbox rights in the sandbox namespace. The gateway stamps its gateway id on every supervisor and Sandbox that it creates, from its own configuration. driver_config cannot set labels or annotations. The ClusterSPIFFEID puts the gateway id in the SPIFFE ID:

spiffeIDTemplate: 'spiffe://{{ .TrustDomain }}/openshell/gateway/{{ index .PodMeta.Labels "openshell.ai/gateway-id" }}/sandbox/{{ index .PodMeta.Annotations "openshell.ai/sandbox-id" }}'

The token endpoint then refuses an SVID whose gateway id is not the bound run's gateway id.

A gateway prefix is spiffe://<trust domain>/openshell/gateway/ for a discovered SPIRE. One named in CONTROLLER_AGENT_SPIRE may hold one {tenant} path segment, such as spiffe://<trust domain>/openshell/{tenant}/gateway/. It then matches IDs of the form spiffe://<trust domain>/openshell/<tenant>/gateway/<gateway id>/sandbox/<sandbox id>, where <tenant> is a DNS-1123 label, for a ClusterSPIFFEID that puts a tenant label of the gateway pod before gateway. The tenant is logged with each token (GatewayPrefix::split, crucible-controller/src/agent_auth/issuer.rs). Nothing else reads it yet. The gateway id check is the same with or without a tenant. The default prefix and the rendered template have no tenant segment, and a prefix without one refuses an ID that has one, and the other way round.

6.2 Lifecycle

flowchart TD
    D["dispatch_run"] --> PO{"isolated profile?"}
    PO -- no --> LOCAL["gateway runs inside the loop pod<br/>(GatewayAccess::Local)"]
    PO -- yes --> PUSH{"push-only cluster?"}
    PUSH -- yes --> REF["refuse: PushOnlyCluster"]
    PUSH -- no --> RES["reserve: gateway Service,<br/>ClusterIP, mint run CA and certs"]
    RES --> SEC["loop client TLS and gateway URL<br/>into the run Secret"]
    SEC --> ST["start: loop Service, NetworkPolicy,<br/>gateway pod, TLS Secrets owned by it"]
    ST --> LP["create loop pod"]
    LP -- "create fails" --> TD1["teardown"]
    ST -- "start fails" --> TD1
    LP --> RUN["run executes"]
    RUN --> COL["collection: delete loop pod, then teardown"]
    COL --> TD2["teardown"]
    TICK["discovery tick: sweep_orphan_gateways"] --> CHK{"is the work_pods row of<br/>its loop pod running?"}
    CHK -- no --> TD3["teardown"]
    CHK -- yes --> KEEP["keep"]

The phases are these:

  • reserve creates the gateway Service and mints the TLS material for its ClusterIP (crucible-controller/src/runs/workpod/gateway.rs:184).

  • start creates the loop Service, the NetworkPolicy and the gateway pod (gateway.rs:256). It then creates the two TLS Secrets with an owner reference to the gateway pod.

  • teardown deletes objects in this order (gateway.rs:337):

    1. the Sandbox resources and supervisor pods with the gateway id label;
    2. the loop Service in the loop namespace;
    3. the gateway's own objects.

    The gateway's own objects go last. A teardown that fails halfway leaves the label that the sweep finds.

  • sweep_orphan_gateways runs on the daemon discovery tick (gateway.rs:398). It finds the gateway objects in each sandbox namespace by crucible.dev/role=openshell-gateway. It removes each gateway whose loop pod row is absent or not running. The daemon registers it only under an isolated profile.

A failed launch removes its gateway at once (crucible-controller/src/runs/workpod/run.rs:1015). Collection removes it after it deletes the loop pod (run.rs:1116). Each failure logs a warning and leaves the rest to the sweep.

Turn pods (grounded rank, scope) get a gateway the same way, with certificates valid for the turn deadline (crucible-controller/src/runs/workpod/spec.rs:409). Their loop Service opens only the OTLP collector port. Turn sandboxes are never bound, so they get no agent tokens.

6.3 Objects

NamespaceObjectNameCreated byRemoved by
looploop pod<run pod>controllercollection
looprun Secretfrom secret_name(<run pod>)controllerowner reference to the loop pod
looploop Service<run pod> (hashed if long)controller startteardown
builditbuilder podsper buildbuilditbuildit clean; the pod deadline stops them
sandboxgateway Service<run pod>-gw (hashed if long)controller reserveteardown
sandboxgateway NetworkPolicy<run pod>-gwcontroller startteardown
sandboxgateway pod<run pod>-gwcontroller startteardown
sandboxserver TLS Secret<run pod>-gw-tlscontroller startowner reference to the gateway pod, and teardown
sandboxsupervisor client TLS Secret<run pod>-gw-clientcontroller startowner reference to the gateway pod, and teardown
sandboxSandbox resourcesper sandboxgatewayowner reference to the gateway pod after bind, and teardown
sandboxsupervisor podsper sandboxgatewayteardown

The gateway objects carry crucible.dev/gateway-for=<run pod> and crucible.dev/role=openshell-gateway. What the gateway creates carries openshell.ai/gateway-id=<gateway id> (crucible/src/deploy/render/gateway.rs:78).

The run Secret holds the loop pod client material under openshell-gateway-ca.crt, openshell-gateway-tls.crt and openshell-gateway-tls.key. The engine reads them as files in /etc/crucible/gateway-tls. The loop pod env gets CRUCIBLE_OPENSHELL_GATEWAY_URL (https://<gateway ClusterIP>:17670) and CRUCIBLE_OPENSHELL_GATEWAY_TLS_DIR.

The gateway pod runs the loop image with the command crucible openshell-gateway (crucible/src/deploy/render/gateway.rs:224, crucible/src/openshell/gateway.rs:886). It gets the driver env of the loop pod (CRUCIBLE_SANDBOX_*, the supervisor and runtime images, the OTLP endpoints), and these per-run values:

EnvValue
CRUCIBLE_GATEWAY_GRPC_ENDPOINTThe URL that supervisors dial back
CRUCIBLE_GATEWAY_HOST_IPThe loop Service ClusterIP: host.openshell.internal inside sandboxes
CRUCIBLE_GATEWAY_CLIENT_TLS_SECRETThe Secret the driver mounts into supervisors
CRUCIBLE_GATEWAY_SERVER_TLS_DIRWhere the server TLS Secret is mounted
CRUCIBLE_GATEWAY_IDThe gateway id

The gateway keeps its state in memory (--db-url sqlite::memory:). Under the kubernetes driver, the gateway accepts any client with a certificate from the run CA as an authorized user (crucible/src/openshell/gateway.rs:507). The run CA is the boundary.

6.4 The per-run CA

GatewayTls::mint (crucible-controller/src/runs/workpod/gateway.rs:90) uses rcgen to make:

  • a CA, constrained to path length 0, with keyCertSign and cRLSign;
  • a server certificate with the gateway Service ClusterIP as its only IP SAN, serverAuth;
  • a loop client certificate, clientAuth;
  • a supervisor client certificate, clientAuth.

All four start one day before the mint date. They end after the run deadline in whole days plus one day, or after 30 days for a run with no deadline. The controller drops the CA key after the mint. No other run trusts this CA, so a certificate of one run cannot call the gateway of another.

6.5 Network policy

The gateway NetworkPolicy (crucible/src/deploy/render/gateway.rs:362) selects the gateway pod. It admits ingress on port 17670 only from:

  • the loop pod of this run (crucible/run=<run> in the loop namespace), and
  • supervisor pods with openshell.ai/component=supervisor and this gateway id.

The loop Service exposes the ports that the rendered loop NetworkPolicy lists for sandbox traffic. These are the broker, the OTLP collector, buildit on 8848 with [buildit], and the [mcp] servers of the pack. Under an isolated profile, the loop render omits port 17670 (crucible/src/deploy/render/kube.rs:1342).

6.6 RBAC for each identity

IdentityNamespaceRightsSource
crucible-openshell-gatewaysandboxsandboxes, sandboxes/status: create, delete, get, list, patch, update, watch; events: get, list, watch; pods: create, delete, get, list, patch, watch; services, networkpolicies: create, get; persistentvolumeclaims: get; secrets: create, deleteloop render sandbox_rbac (kube.rs:1473)
crucible-openshell-gatewayclustertokenreviews: create; nodes: get, list, watch; runtimeclasses, priorityclasses, namespaces: getsame
sandbox service account ([cluster] sandbox_service_account, default crucible-sandbox under isolated gateways)sandboxnone. Sandbox and supervisor pods mount no token; the account needs only what admits them (an OpenShift SCC). The render creates itloop render sandbox_rbac
controller identity ([cluster] sandbox_readers)sandboxsandboxes: get, list, patch, delete; pods: get, list, create, delete; pods/finalizers: update; services, secrets, networkpolicies: list, create, deleteloop render sandbox_reader_rbac (kube.rs:1658)
controller identityloopservices: list, create, delete; plus the dispatch rights in docs/controller-deploy.mdsame, and the operator
controller identitySPIREservices/proxy: getthe operator
controller identityclustertokenreviews: create (system:auth-delegator), for linksthe operator
loop service accountsandboxnone under an isolated profileloop render
loop service accountbuilditpods: create, delete, get, list, watch; pods/exec: create, get; pods/log: get. The Role and RoleBinding are <service account>-buildit, for buildit builder pods. None in the loop namespaceloop render buildit_rbac with [buildit] (kube.rs:1707)

Note: pods/finalizers update is necessary on OpenShift. The TLS Secrets have an owner reference with blockOwnerDeletion to the gateway pod, and the OpenShift owner-reference admission checks that right.

Note: The loop render grants the builder pod rights only when the profile has [buildit], and only in [buildit] namespace. The render does not depend on sandbox_driver.

Warning: The loop service account is shared by every run. Rights to create pods or exec into pods in the loop namespace would let one run's pack code reach another run's loop pod and run Secret: its gateway client certificate, pod token, push authfile and kubeconfig. It could also label a pod crucible/run=<other run> to pass that run's gateway NetworkPolicy. That is why builder pods live in a namespace of their own, which profile load refuses to share with the loop, rig, sandbox or crucible-kueue namespaces. The rights in the buildit namespace still let runs see each other's builder pods there, including a push's authfile while buildit streams it into the builder pod. The clean end state is buildit under a service account of its own.

With [cluster] aws_sandbox_role_arn, the gateway pod gets its own projected token for the sts.amazonaws.com audience. The trust policy of that role must allow system:serviceaccount:<sandbox namespace>:crucible-openshell-gateway.

Pod admission in these namespaces depends on the cluster's platform (section 11.2). The gateway and loop pods set no UID: on OpenShift the namespace's SCC assigns one, and on plain Kubernetes they run as their image's user, root, so their namespaces enforce Pod Security baseline there. OpenShell's sandbox and supervisor pods set their own non-root UID, drop every capability and use RuntimeDefault seccomp, so they would pass restricted on either platform.

6.7 Push-only clusters and directly reachable clusters

The controller creates gateway objects, reads Sandbox resources and reads SPIRE keys through its own API client for the cluster. A push-only cluster sends snapshots to the hub, and the hub has no client for it. So under an isolated profile, dispatch to a push-only cluster fails with PushOnlyCluster (crucible-controller/src/runs/workpod/run.rs:557, run.rs:707).

A cluster is push-only when the hub has a push token for it and no client for it. A cluster has a client when it is the hub, a personal target, or has a kubeconfig in CONTROLLER_CLUSTERS_DIR. A cluster with both a push token and a kubeconfig dispatches normally.

Note: The token path depends on the per-cluster client. When the hub cannot reach a spoke, the sandboxes of that spoke get no tokens until the spoke is reachable again.

6.8 When sandbox_namespace is unset

AreaIsolated profilesandbox_namespace unset
GatewayA pod of its own per run and per turnInside the loop pod
Sandboxes and supervisorsSandbox namespaceLoop namespace
Sandbox RBACcrucible-openshell-gatewayThe loop service account
Loop NetworkPolicyNo gateway portAdmits sandbox pods on 17670
Agent tokensIssued on each cluster whose SPIRE the issuer trustsNot issued; with CONTROLLER_AGENT_SPIRE set, the controller does not start (PlacementError)
Token endpoint without agent clustersunsupported_grant_typeunsupported_grant_type
Bind routeServed409: the profile names no sandbox namespace
Gateway sweepRegisteredNot registered
Push-only dispatchRefusedAllowed
People tokensAllowedAllowed (they vouch for no sandbox)

Profile load also refuses a sandbox namespace that is the loop namespace, the rig namespace, the crucible-kueue namespace or the workload namespace (crucible/src/deploy/profile.rs:859).

6.9 SPIRE discovery

The issuer verifies a sandbox's JWT-SVID against the SPIRE of the cluster that issued it, so it needs each agent cluster's JWT issuer, trust domain and the Service its keys are read through. It finds them itself (crucible-controller/src/agent_auth/discovery.rs), through the same cluster client it dispatches with, for every cluster an agent server serves:

PlatformTrust domain and JWT issuerKeys
OpenShift (serves security.openshift.io)spec.trustDomain of the ZeroTrustWorkloadIdentityManager and spec.jwtIssuer of the SpireOIDCDiscoveryProvider, both named cluster (operator.openshift.io/v1alpha1)spire-spiffe-oidc-discovery-provider in zero-trust-workload-identity-manager, https:443
Kubernetesserver.trust_domain and server.jwt_issuer in the server.conf of ConfigMap spire-server/spire-server (the upstream chart)spire-spiffe-oidc-discovery-provider in spire-server, http:80

The gateway prefix is spiffe://<trust domain>/openshell/gateway/. Discovery runs at startup and each minute for every cluster not yet read, and every ten minutes for all of them. A cluster it cannot read, or whose SPIRE runs in a namespace pack code reaches, has agents off: its runs get no agent servers, the issuer refuses its SVIDs, the controller logs why, and GET /api/agent-clusters reports each cluster as ready, pending or unavailable with the reason. CONTROLLER_AGENT_SPIRE names a SPIRE installed anywhere else and replaces discovery for that cluster.

The run render grants the identities in [cluster] sandbox_readers what discovery reads, as crucible-spire-reader: get on services/proxy for the discovery Service in SPIRE's namespace, and the spire-server ConfigMap on Kubernetes, or a ClusterRole for the two operator resources named cluster on OpenShift.

7. crucible-kueue internals

7.1 Request pipeline

flowchart LR
    REQ["POST /mcp"] --> AUTH["auth::authenticate<br/>verify token, required scope kueue.read"]
    AUTH --> CAN["ClaimMap::canonicalize<br/>map configured claims to kube_user,<br/>kube_groups, run_id, namespace"]
    CAN --> DNS["default_namespace<br/>the only usable namespace, if one"]
    DNS --> ID["Identity::from_principal<br/>run or person, kube_user required"]
    ID --> GUARD["guard::enforce<br/>method allowlist, per-tool scope,<br/>tool policy, access checks"]
    GUARD --> RMCP["rmcp Streamable HTTP<br/>stateless, JSON responses"]
    RMCP --> TOOL["KueueMcp tool<br/>acts as the server service account"]
    META["GET /.well-known/oauth-protected-resource/mcp"] --> MD["RFC 9728 metadata, no auth"]
    HZ["GET /healthz"] --> OK["ok, no auth"]

The app is built in crucible-kueue/src/lib.rs:62:

  1. auth::authenticate verifies the bearer token and checks kueue.read (crucible-kueue/src/auth.rs:117). A token that fails verification gets a 401 with the RFC 9728 challenge. A token without kueue.read gets a 403 step-up challenge.
  2. ClaimMap::canonicalize copies the configured claims into the canonical names, and removes a canonical claim that the configured claim does not supply (crucible-kueue/src/config.rs:289). A token cannot set kube_user itself unless kube_user is the configured user claim.
  3. A token without namespace gets the one namespace the caller can use, when there is exactly one (auth.rs:36).
  4. Identity::from_principal refuses a people token with run_id, a people token on a server with no reviewer, and any token without kube_user (auth.rs:68). The refusal is a 403. A run's identity is its run_id; a person's is its sub. A value that is not a label value gets a hash suffix.
  5. The guard decides the request (decide, crucible-mcp-auth/src/guard.rs:148). initialize, ping, server/discover, notifications/* and responses to the server pass. tools/list passes and has injected parameters removed from each inputSchema. Every other method must be listed in KueueRules. JSON-RPC batches are refused.
  6. For a listed call, the guard checks the scope. For a tool, it then applies the tool policy. It then sends each access check to the reviewer (review, guard.rs:106). The request goes on as the guard parsed and rewrote it.
  7. A refused tool call returns a tool result with isError and the text {"status": "refused", "error", "retryable"}, so the model can read the reason. Only an access review that did not complete (the API server or the group lookup failed) is retryable; a caller the reviewer cannot check (an empty or system: kube_user, malformed kube_groups) is refused for good (ReviewError, crucible-mcp-auth/src/access.rs). An absent scope returns a 403 step-up challenge. A refused method returns JSON-RPC error -32001.

7.2 Tools

Tool or methodScopeAccess check as kube_user
whoamikueue.readnone
queues_listkueue.readlist localqueues
queue_describe, hardware_describekueue.readget or use (the submit verb) the LocalQueue
workload_status, tasks/getkueue.readget the workload; each of Kueue workloads, pods and events is shown only with get and list on it
workload_listkueue.readlist Kueue workloads
workload_logskueue.readget pods/log
workload_plan, workload_submitkueue.submituse (or get) the LocalQueue, create the kind, get each Secret and PVC the manifest names
workload_scalekueue.submitupdate the scale subresource
workload_stop, workload_resume, tasks/cancelkueue.submitupdate the workload, and patch Kueue Workloads in the namespace for a kind that does not scale (its Workload's spec.active is patched)
workload_deletekueue.submitdelete the workload

The namespace of each check is the call's namespace argument, else the token's namespace claim. Every tool takes an optional namespace. tasks/get and tasks/cancel take no namespace: they act in the namespace the task id names (7.9). Each check covers the object named; what a read returns beyond it is read with the server's account (2.6).

Note: A unit test fails if a tool has no scope in KueueRules. A new tool cannot ship unchecked.

7.3 Plan, then submit

sequenceDiagram
    autonumber
    participant A as agent
    participant K as crucible-kueue
    participant API as API server
    A->>K: workload_plan {queue, manifest}
    K->>API: SubjectAccessReviews (queue use, create kind, Secrets, PVCs)
    K->>API: get LocalQueue and its ClusterQueue
    Note over K: normalize the manifest, compute plan_hash
    K->>API: create with dryRun, plan-hash and actor annotations
    K-->>A: plan_hash, fit, pod_sets, server_changes, manifest
    A->>K: workload_submit {queue, manifest, plan_hash}
    K->>API: the same reviews
    Note over K: normalize again, compare plan_hash
    alt hash differs
        K-->>A: error that names both hashes
    else object absent
        K->>API: create
        K-->>A: submitted, uid, task_id
    else object exists with the same plan hash
        K-->>A: already submitted with this plan
    else object exists with another plan hash
        K-->>A: conflict
    end

workload_plan normalizes the manifest, runs a server-side dry run, and estimates the fit against the ClusterQueue quota as fits_now, waits_for_quota or never_fits (crucible-kueue/src/server.rs:1604). It returns plan_hash, finished_retention_seconds, server_changes, the admission mode, the pod sets, the total requests and the normalized manifest.

workload_submit normalizes the same input again and compares the hash (server.rs:1658). The hash is the SHA-256 of the normalized object as key-sorted JSON (crucible-kueue/src/manifest.rs:504). It depends only on content, so the agent must send the same queue and the same manifest. An identical resubmit is a no-op. A name that holds another plan is a conflict.

After the hash, the server stamps two annotations: <domain>/plan-hash, and for a run, <domain>/actor with the SPIFFE ID of the presenter (server.rs:288).

7.4 Manifest allowlist and profiles

The allowlist is data (crucible-kueue/src/profile.rs). Anything not on the list is refused.

Liststricttenant adds
Pod fieldscontainers, initContainers, restartPolicy, terminationGracePeriodSeconds, activeDeadlineSeconds, dnsPolicy, dnsConfig, nodeSelector, affinity, topologySpreadConstraints, securityContext, volumes, imagePullSecrets, hostname, subdomain, setHostnameAsFQDN, enableServiceLinks, shareProcessNamespace, hostAliases, readinessGates, automountServiceAccountTokentolerations, priorityClassName, resourceClaims
Container resourcesrequests, limitsclaims
Volume sourcesemptyDir, configMap, downwardAPI, ephemeral, projectedsecret, persistentVolumeClaim
Projected sourcesconfigMap, downwardAPIsecret
env[].valueFromfieldRef, resourceFieldRef, configMapKeyRefsecretKeyRef
envFrom[]configMapRef, prefixsecretRef
Reserved label domainskueue.x-k8s.io, openshell.ai, spiffe.io, and the server label domainonly the server label domain
Allowed reserved labelsnonekueue.x-k8s.io/priority-class
Allowed reserved annotationsnonekueue.x-k8s.io/podset-required-topology, kueue.x-k8s.io/podset-preferred-topology

A TOML file starts from base = "strict" or base = "tenant" and has optional [add] and [remove] tables with the same list names. The tool policy can make any rule stricter.

The pod field list has no serviceAccountName, hostNetwork, hostPID, hostIPC, nodeName, schedulerName or runtimeClassName. So a manifest cannot pick another identity, the host, or a way past Kueue. The object-level spec of each kind is also on a list, so fields such as managedBy never reach the cluster.

7.5 Normalization

normalize (crucible-kueue/src/manifest.rs:303) does these steps:

  1. Find the kind from apiVersion and kind. Seven kinds are known: Job, RayJob, LeaderWorkerSet, Deployment, StatefulSet, JobSet, PyTorchJob.
  2. Remove status, uid, resourceVersion, creationTimestamp and managedFields. Refuse generateName, ownerReferences and finalizers.
  3. Require metadata.name as a DNS-1123 label.
  4. Set metadata.namespace to the caller namespace. Refuse another value.
  5. Refuse reserved labels and annotations that the profile does not allow (manifest.rs:822).
  6. Set the object labels: kueue.x-k8s.io/queue-name, <domain>/managed-by=crucible-kueue, <domain>/run-id, <domain>/owner-kind (run or person), <domain>/launched-by. A manifest value that conflicts is refused.
  7. With --suspend (default true), create Jobs and JobSets with spec.suspend: true, and PyTorchJobs with spec.runPolicy.suspend: true, for Kueue to start on admission.
  8. For batch kinds, add activeDeadlineSeconds (default 86400) and ttlSecondsAfterFinished (default 3600) where the manifest sets none (manifest.rs:228). A JobSet gets the deadline on each replicated Job; a PyTorchJob gets both on runPolicy; a RayJob gets the TTL only with shutdownAfterJobFinishes: true.
  9. Check the object-level fields of the kind. A RayJob may not enable the in-tree autoscaler, and its head service must be ClusterIP. Volume claim templates may set only access modes, resources and volume mode.
  10. In each pod template: check reserved metadata, set <domain>/workload and <domain>/run-id, check the pod spec (manifest.rs:1142), and set automountServiceAccountToken: false. A manifest with true is refused.
  11. Check each container. Refuse privilege, privilege escalation, root and added capabilities. Accept procMount only as Default, and seccomp and AppArmor only as RuntimeDefault. Refuse a probe or hook with a host.
  12. Add pull secrets (section 7.6).
  13. For a Deployment or StatefulSet, pin spec.selector.matchLabels to the workload and run-id labels, so its controller adopts only pods of this object (manifest.rs:1122).

server_changes in the plan lists what step 12 added.

7.6 Pull secrets

--pull-secret name or --pull-secret name=registry declares a pull secret of the served namespaces. A pod template may name only declared pull secrets. When a container image is on a declared registry (a host, or a host/path prefix), the server adds that secret to the pod itself (crucible-kueue/src/manifest.rs:433). An image on another registry must be public.

Warning: The server does not create the pull secret. It must exist in the workload namespace with that name, or the pods cannot pull. crucible deploy render --kueue does not copy it.

7.7 Access checks and namespaces

The reviewer (crucible-mcp-auth/src/kube.rs) builds a SubjectAccessReview with:

  • user: the canonical kube_user, never a system: user;
  • groups: OpenShift Group objects that list the user plus system:authenticated and system:authenticated:oauth (--kube-authz openshift), or the kube_groups claim without system: entries plus system:authenticated (--kube-authz token, where the token is not an OpenShift OAuth sign-in);
  • the resource attributes of the access check.

The server serves the namespaces in --namespace, else every namespace that holds a LocalQueue. A caller can use a served namespace when its RBAC lets it list LocalQueues there (crucible-kueue/src/namespaces.rs:97-115). The answer is cached 30 seconds per identity (subject, kube_user and kube_groups), so a RoleBinding granted or removed shows in the usable list within 30 seconds; the guard's per-call access decisions are cached as section 2.6 says. Every tool call and both task methods resolve the namespace they act in (the call's namespace argument, the token's namespace claim, or the task id's namespace) and refuse one the server does not serve, or one the token's namespaces claim leaves out, with not_found and the list of usable namespaces. The guard has already run the call's SubjectAccessReviews by then, so those reach the API server first; the check comes before the server reads or writes anything with its own account. Identity::act_in (crucible-kueue/src/caller.rs) makes this check and is the only way to get a Caller, the type every tool acts with, so a tool cannot skip it. A test calls every tool the router lists in an unserved namespace and expects not_found (crucible-kueue/tests/http_auth.rs). The usable list is also cut to the claim. A server with no --namespace that cannot list LocalQueues cluster-wide serves nothing. It never passes on the API server message about the server's own account.

7.8 Status rollup and the wait

Kueue can track one object as several Workloads. A Deployment gets one Workload for each pod. rollup (crucible-kueue/src/status.rs:138) gives each Workload a phase: NoWorkload, Pending, QuotaReserved, Admitted, Evicted, Stopped, Finished. The headline is the phase furthest from the run state, with counts for each phase. So pending with admitted > 0 means partly admitted.

workload_status reports the phase and the reason. While the workload waits, it also reports the queue position and a new fit estimate. It then reports pod counts, up to five problem pods with exit messages, recent events and a next_action. detail: "full" adds all conditions, the object status and every pod. For a caller with a kube_user, the Workload fields (phase, counts, queue position, fit, readiness, next_action), the pod fields and the events each need get and list on Kueue workloads, pods and events in the namespace; a part without them is left out and listed under omitted with the reason.

With until (admitted, running, ready or finished) the server waits for up to timeout_seconds, at most 50, and polls each 2 seconds (crucible-kueue/src/server.rs:1228). The wait ends with one of these:

endedCause
reachedThe workload is at until or past it
settledThe workload is Finished or Stopped and cannot reach until alone
stuckA pod has a problem of the highest severity
timeoutThe time is over

7.9 MCP tasks

The server enables the MCP tasks extension for batch kinds (Job, RayJob, JobSet, PyTorchJob). A client that declares the extension gets a task from workload_submit. The task id is <namespace>/<Kind>/<name>/<uid> (crucible-kueue/src/task.rs:16), so a new object with the same name is a different task. Clients without the extension get a synchronous reply that still carries task_id.

The server keeps no task state. tasks/get reads the cluster each time. A task completes when Kueue marks the Workload finished. It failed unless the reason is Succeeded. tasks/cancel sets the <domain>/task-cancelled annotation and deactivates the Workload (server.rs:2175).

Serving kinds are never tasks. workload_status reports their readiness instead.

7.10 Ownership modes

ModeStop, resume, scale, delete
managed (default)Only objects with <domain>/managed-by=crucible-kueue, the caller's <domain>/run-id and the caller's kind in <domain>/owner-kind, so a person whose subject reads like a run id owns nothing of that run
rbacAny object of a served kind that the access checks allow

workload_list takes filter: mine (default, this run), managed (all objects of this server), or all (every Kueue-queued workload of a served kind). Stop and resume deactivate the Kueue Workload for Job, RayJob, LeaderWorkerSet, JobSet and PyTorchJob. For a Deployment or StatefulSet they scale to zero and keep the replica count in <domain>/stopped-replicas.

7.11 Kueue version and kinds

At startup the server reads /apis/kueue.x-k8s.io and uses v1beta2, else v1beta1. It does not start when the cluster serves neither (crucible-kueue/src/kueue.rs:49). It also lists which of the seven kinds the cluster installs (crucible-kueue/src/kinds.rs:11). whoami and the server instructions report them.

7.12 Generic configuration

The server does not need crucible. These flags make it a general Kueue MCP server for a cluster with a stock OIDC provider:

FlagDefaultUse
--label-domaincrucible.devThe domain of every stamped label and annotation
--managercrucible-kueueThe <domain>/managed-by value and the field manager of every write. Servers sharing a cluster each need their own, so each sees only its objects as managed
--user-claimkube_userThe claim that names the Kubernetes user
--user-prefixemptyThe prefix of the user, as --oidc-username-prefix on the API server
--groups-claimkube_groupsThe groups claim, with --kube-authz token
--run-id-claimrun_idThe run claim of run tokens; empty for none, which is refused with --run-issuer
--namespace-claimnamespaceThe default namespace claim
--namespaces-claimnamespacesThe claim listing the namespaces a token is confined to; a token without it is confined only by the served namespaces and RBAC
--queue-verbuseuse, or get where roles do not grant Kueue's custom verb
--ownershipmanagedmanaged or rbac
--allowlist-profilestrictstrict, tenant, or a TOML file

8. buildit per run

8.1 Where it runs

buildit runs as a child process of the engine in each loop pod of a [buildit] cluster. It is an entry of CRUCIBLE_AGENT_SERVERS like any other, named buildit, whose transport is the loop pod (local.buildit, carrying its run issuer, builder namespace and push repository). The engine starts it before it creates the providers (Granted::start_local, crucible/src/openshell/agent_servers.rs), and gives it a provider, an egress entry and a .mcp.json entry the way it does every server. It listens on the port of its URL, [buildit] url, by default http://host.openshell.internal:8848/mcp, which is the loop Service. The supervisor injects a token for that audience on egress to that host and port only.

The engine runs the buildit first on its PATH. That is the loop image's own unless [buildit] image names another: the buildit-bin init container then copies that image's /usr/local/bin/buildit into an emptyDir the loop container mounts read-only at /opt/crucible/buildit/bin, and the wrapper puts that directory first on PATH, so the engine and the run's own tasks run it. The shared cache runs from the same image unless [buildit.cache] image names one. docker.yml publishes ghcr.io/neuralmagic/crucible-buildit from every main build (docker/Containerfile.buildit), and the controller reads the deploy profile at each dispatch, so moving the pin rolls buildit for the next runs with no new loop image.

The engine starts it with these arguments (Buildit::args, crucible/src/openshell/buildit.rs):

ArgumentValue
--bind0.0.0.0:<port of the URL>
--run-issuerthe entry's <issuer>=<jwks uri>
--resourcethe entry's URL
--allowed-hostthe URL's host
--tool-policythe shipped buildit/deploy/buildit-policy.toml
--run-idthe loop pod name
--namespacethe entry's builder namespace, [buildit] namespace
--insecure-jwks-httpwhen the JWKS URI is plain http
--push-repo, --push-authfilewhen the entry has a push repository and the loop pod has a push authfile
--socket, --socket-workdir<state dir>/buildit.sock and the run's workspace (8.6)

8.2 Who may call it

realm_caller (buildit/src/mcp.rs:832) accepts a token only when all of these are true:

  • The trusted issuer is a run issuer. People tokens are refused.
  • run_id is this server's --run-id. A token of another run is refused.
  • sandbox is a label value. It names the sandbox to read from.
  • workdir, when present, is an absolute normal path.
  • sub is a label value. It becomes crucible.dev/launched-by on builder pods.

The scopes are buildit.read for status, logs and tasks/get, and buildit.build for build, run, push, clean and tasks/cancel. buildit does no access checks: it builds in its own namespace for the run's own sandboxes.

8.3 How it reads the sandbox

For build, buildit downloads the build context with openshell sandbox download <sandbox> <workdir>/<context> <dest> through the run's gateway (buildit/src/sandbox.rs:101). The context path must be relative, without ... It may not contain .git, .mcp.json, .claude, .buildit, .kube or .jira. sanitize then deletes those names but .git, special files, and symlinks that resolve outside the context (sandbox.rs:207). Of each .git directory it keeps HEAD, index, objects, packed-refs, refs and shallow, so a build can read the checkout's history (vLLM's setup.py takes its version from it), and deletes the rest: the config, where remote URLs and http headers can carry credentials, the hooks and the logs. run writes requested output paths back to <workdir>/.buildit/<build id>/ in the sandbox with openshell sandbox upload.

8.4 Builder pods

Each build gets one builder pod in the buildit namespace (buildit/src/mcp.rs:971). The pod has the labels buildit.dev/managed-by=mcp, buildit.dev/sandbox, buildit.dev/build-id and crucible.dev/launched-by. It runs buildah with automountServiceAccountToken: false, enableServiceLinks: false and an activeDeadlineSeconds of --pod-deadline (default 7200). --max-builds (default 4) limits live builder pods per sandbox. The shipped policy caps a build at 3600 seconds and a run at 1800 seconds; without a value they are 1800 and 600.

Warning: A build's RUN steps and buildit run execute the agent's code in the builder pod, with no OpenShell sandbox around it. The loop render's NetworkPolicy buildit-builders in the buildit namespace admits no ingress to builder pods, and egress only to DNS (UDP and TCP 53 and 5353) and to [buildit] egress, by default TCP 443 anywhere, for the registries a build pulls from and pushes to. A NetworkPolicy matches IP networks, not host names: to keep RUN steps away from in-cluster services, list the registries' networks, or add as except the cluster's pod network, its service network (which holds the API server's ClusterIP, the kubernetes Service in default, such as 172.30.0.1 on OpenShift or 10.96.0.1 on kubeadm), and the API server's own addresses when it is reached outside the service network. The pod and service networks are in oc get network.config cluster -o yaml on OpenShift, or the kube-controller-manager's --cluster-cidr and --service-cluster-ip-range. The loop render logs a warning when an egress entry reaches 0.0.0.0/0 or ::/0 with no except, as the default does (BuilditCfg::unfenced_egress).

The loop account may create any pod in the buildit namespace, and builder pods need AppArmor unconfined, so that namespace cannot enforce Pod Security baseline. Without more, pack code could create a privileged pod, mount the node's / or join its network, and from the node reach every other run. The loop render therefore also emits a ValidatingAdmissionPolicy and its binding to the buildit namespace, both named <namespace>-buildit-builders, from buildit/deploy/builder-admission.yaml (builder_admission, crucible/src/deploy/render/kube.rs). It admits a pod there only when:

  • it carries buildit.dev/managed-by=mcp;
  • it runs one container, builder, from quay.io/buildah/stable:latest, with no init or ephemeral containers;
  • the container is not privileged, adds no capabilities, runs as a non-root runAsUser, does not ask for seccomp Unconfined, keeps the default /proc mount, and sets no SELinux user, role or type;
  • it sets no nodeName (on create), runtime class, system priority class, sysctls, or toleration beyond the not-ready and unreachable ones admission gives every pod;
  • it shares no host network, PID or IPC namespace, and mounts only emptyDir volumes;
  • it runs as the default account with automountServiceAccountToken: false;
  • it reads no Secret or ConfigMap into its environment (valueFrom, envFrom) and names no image pull Secret;
  • AppArmor is set only as buildit sets it: the annotation for builder, unconfined (or the matching appArmorProfile field on that container).

The policy checks pod creates and updates, so an update cannot swap the image or the labels. It admits what admission defaults every pod with: those two tolerations, a cluster's global default PriorityClass, and an SELinux level an OpenShift SCC assigns. The policy needs Kubernetes 1.30 or later.

The loop render also emits a ResourceQuota in the buildit namespace from [buildit] quota, by default 16 pods and no NVIDIA or AMD GPUs. Builder pods request no CPU or memory, so a quota on requests.cpu or requests.memory would refuse every one. buildit's tests evaluate its own builder pod against the same file and expect it to pass, and expect each escape above to fail (the_builder_admission_policy_admits_buildits_pod_and_no_escape, buildit/src/mcp.rs), so the policy and the builder cannot drift apart.

On OpenShift, SCC admission does the same job when the builder pods' account (default in the buildit namespace) can use only an SCC no broader than this: allowPrivilegedContainer: false, allowedCapabilities: [], allowHostNetwork, allowHostPID, allowHostIPC and allowHostDirVolumePlugin false, volumes: [emptyDir], runAsUser: MustRunAsNonRoot, and the unconfined AppArmor annotation allowed. Do not grant that account, or the loop account, anyuid or privileged; the loop account's pods in that namespace run as whatever account the pod names, so every account there needs the same bound. The ValidatingAdmissionPolicy applies on OpenShift too, so both hold.

8.5 The push path

push publishes a build that succeeded to one repository only: --push-repo, for example registry.example.com/team/smoke. The tag is <sandbox>-<build id>. The repository must be <registry>/<path> with no tag, digest or scheme (buildit/src/mcp.rs:457).

sequenceDiagram
    autonumber
    participant A as agent
    participant B as buildit (loop pod)
    participant P as builder pod
    participant R as registry.example.com
    A->>B: push {build_id}
    Note over B: build must have exited 0
    B->>B: read the push authfile from the loop pod mount
    B->>P: exec a shell that writes stdin to a temp file and runs buildah push<br/>authfile on stdin
    P->>R: push REPO:SANDBOX-BUILD_ID
    P-->>B: sha256 digest on stdout
    Note over P: the temp files are removed on exit
    B-->>A: image REPO@sha256 digest, tag

The authfile is the loop pod's [secrets] push_authfile Secret, mounted in the loop pod only (FORGE_AUTHFILE). buildit reads it on each push and streams it to the builder pod over exec stdin (buildit/src/mcp.rs:479, mcp.rs:1089). The builder pod never mounts it, and the sandbox never sees it. Without a push repository or without the authfile, push is refused.

Warning: the push runs in the same builder pod as the agent's build steps and run commands, as the same user, and nothing stops a process a step left behind when push starts. Such a process could read the authfile while the push holds it, then push to any tag, or any repository the credential reaches. Until the push moves to a container of its own, give [secrets] push_authfile a credential scoped to the one push repository.

8.6 The run's own socket

The same buildit mcp also serves its tools on a unix socket, <state dir>/buildit.sock (mode 0600), for the run's deterministic tasks. The socket takes no token. Every call on it is the run itself, under the sandbox name loop-<first 16 hex of sha256(run id)>, which no openshell sandbox (ci-<hex>) and no other run takes. That name scopes --max-builds, clean and every lookup the way a sandbox name does, and buildit refuses any token that names it. The shipped tool policy, the builder namespace, the queue and the push repository are the same as over HTTP.

The engine exports the socket as CRUCIBLE_BUILDIT_SOCKET to command and evaluate tasks only (ShellRunner, crucible/src/plan/runner.rs). Agent turns and sandboxes never get it, and route and report tasks spawn nothing. Before such a task runs, the engine starts the run's buildit if the controller granted one (run_buildit, crucible/src/openshell/agent_servers.rs), and the task fails as a transport error when buildit does not come up. With the variable set, buildit build, status, run, push, logs and clean call the socket. Each prints one JSON object on stdout and exits nonzero on failure:

buildit build -c image/            # waits for the build to end; --no-wait returns at once
buildit context-digest image/      # the context_digest a build of image/ reports

A build on the socket reads its context from the run's workspace. The CLI sends -c as an absolute path, and buildit refuses one outside the workspace, so a task in an isolated worktree cannot build its own copy. run writes fetch_paths to <workspace>/.buildit/<build id>/.

Every build reply carries context_digest: a sha256 over the context archive buildit uploaded, one record per entry sorted by path (path, file mode bits, content hash or symlink target; owners, times and directory modes do not count). buildit keeps it on the builder pod, so status, a completed build task and push report it after a server restart too. `buildit context-digest

` stages a directory the way a build does and computes the same digest with the same code, so a script can check that what was built is what it prepared.

8.7 The shared cache

With [buildit.cache], crucible deploy --buildit-cache renders buildit cache in the loop namespace (crucible/src/deploy/buildit_cache.rs): a PersistentVolumeClaim, a Deployment that mounts it and the token key of token_secret, a Service on 8850, and a NetworkPolicy that admits builder pods from the builder namespace alone. The loop render adds an egress rule to it to the buildit-builders NetworkPolicy.

The cache is a blob store over HTTP (buildit/src/cache.rs). GET and HEAD are open; PUT and DELETE need the write token, as a bearer token or a basic-auth password, which is how ccache's http backend sends a URL's userinfo. It re-reads the token file on every write and drops the least recently read blobs past its limit.

The cache serves the dispatch clusters in [buildit.cache] clusters, each one of [buildit] clusters, and is deployed on each. The controller puts it in the buildit site of a run on one of them (BuilditSite.cache): the Service URL and, with write, the token file /etc/buildit-cache/token. Runs on other clusters are told nothing of it. With write, every loop pod mounts the token Secret there, as an optional volume so a cluster without the Secret still starts its runs. The loop passes the site to buildit as --cache-url and --cache-token-file, and buildit writes /buildit/cache.json into each build before bud: {"url": ...}, with "token" when it has one. A build uses the cache only if its RUN steps read that file, as the vLLM bench's informer does for ccache and its build store.

Warning: with write, RUN steps can read the token, so any run's agent on the cluster can store objects that other runs' builds then use. Set write only where runs trust each other.

9. Configuration reference and feature flags

9.1 Minimal configuration

An operator states what only they can decide: which clusters get which servers, where agents' workloads go, whether people get tokens, and where builds may be pushed. The controller environment adds the issuer and its key:

CONTROLLER_AGENT_ISSUER=https://crucible.example.com
CONTROLLER_AGENT_SIGNING_KEY_FILE=/etc/crucible/agent-signing/key.pem
CONTROLLER_DEPLOY_PROFILE=/etc/crucible/profile/profile.toml
[cluster]
# ...the keys every profile has (loop_namespace, service_account, supervisor_image, ...), plus:
sandbox_namespace = "crucible-sandboxes"
sandbox_readers   = ["crucible-system/crucible-controller"]

[kueue]
clusters            = ["pirate", "waldorf"]
workload_namespaces = ["crucible-kueue-e2e"]
people              = true
people_urls         = { pirate = "https://kueue.pirate.example.com/mcp" }

[buildit]
clusters  = ["pirate", "waldorf"]
push_repo = "quay.io/example/crucible-smoke"

people_urls is needed once people covers more than one cluster: every cluster's server has the same Service URL, and a person's token names its cluster by URL, so all but one cluster need a URL of their own. deploy render --kueue also needs [kueue] image.

9.2 Derived values

ValueSource
Agent token resources and their scopesThe servers in [kueue], [buildit] and [[agent_servers]] (Audience::of)
People resources[kueue] people with people_urls, and [[agent_servers]] people
Each cluster's SPIRE: SVID issuer, gateway prefix, key ServiceDiscovered from the cluster (section 6.9)
The run token issuer crucible-kueue and buildit trust, and their sandboxes' SVID audienceCONTROLLER_AGENT_ISSUER
The key set URI servers fetch<CONTROLLER_AGENT_ISSUER>/.well-known/jwks.json
The token endpoint supervisors call<CONTROLLER_AGENT_ISSUER>/oauth/token
Each cluster's platformDetected: OpenShift serves security.openshift.io
crucible-kueue's group source (--kube-authz)The cluster's platform
crucible-kueue's Service URL[kueue] namespace and cluster_domain
crucible-kueue's extra --resource and --allowed-host[kueue] people_urls
The cluster link callback baseCONTROLLER_AGENT_ISSUER
The sandbox compute driverkubernetes with a sandbox_namespace
The sandbox service accountcrucible-sandbox with a sandbox_namespace
The supervisors' SPIFFE socket/spiffe-workload-api/spire-agent.sock, delivered only with a run's agent servers
The SPIRE reader RBAC (crucible-spire-reader)Rendered for sandbox_readers on each cluster's platform
The server and builder namespacescrucible-kueue and crucible-builds

Every other key and variable in sections 9.3 and 9.4 is an override with the default shown.

9.3 Overrides: controller environment

VariableDefaultWhat it does
CONTROLLER_AGENT_ISSUERunsetThe issuer identifier, an http(s) origin with no path. Unset disables the issuer, agent tokens, people tokens and the agent tools. Cluster links depend only on [cluster_link].
CONTROLLER_AGENT_SIGNING_KEYunsetThe issuer key as inline PEM.
CONTROLLER_AGENT_SIGNING_KEY_FILEunsetThe issuer key file. Necessary when the issuer is set and the inline key is not.
CONTROLLER_AGENT_PREVIOUS_SIGNING_KEY_FILEunsetThe previous issuer key. The JWKS publishes it; it signs nothing.
CONTROLLER_AGENT_TOKEN_TTL_SECS900Agent token lifetime, 900 to 1800.
CONTROLLER_AGENT_TOKEN_ENDPOINT<issuer>/oauth/tokenWhere supervisors reach the token endpoint, for a spoke that reaches the controller through a route.
CONTROLLER_AGENT_SPIREdiscoveredAn override for a cluster whose SPIRE is not where its platform's installer puts it: comma-separated <cluster>=<svid issuer> <gateway prefix> [http://]<namespace>/<service>:<port>. Each cluster and each SVID issuer once. The prefix may hold one {tenant} segment (section 6.1). Every other cluster an agent server serves has its SPIRE discovered (section 6.9). A SPIRE whose namespace pack code reaches is refused: a named one stops the controller, a discovered one leaves agents off on its cluster.
CONTROLLER_PEOPLE_ACCESS_TTL_SECS900People token lifetime, 1 to 3600.
CONTROLLER_PEOPLE_CLIENTS_FILEunsetA TOML file of [[clients]] with client_id, name, redirect_uris and optional resources. It replaces the built-in claude-code, pi and codex. Unset: the built-in clients (section 4.1.2).
CONTROLLER_PEOPLE_CIMD_HOSTSunsetComma-separated host names that may publish Client ID Metadata Documents. Unset: every URL client_id is refused (section 4.1.3). The controller needs outbound HTTPS to these hosts. It honours HTTPS_PROXY.
CONTROLLER_PEOPLE_DCRonfalse, 0, off or no turns off POST /oauth/register and removes registration_endpoint from the metadata. true, 1, on, yes or empty keeps it on (section 4.1.4).
CONTROLLER_PEOPLE_TRUSTED_PROXIESunsetComma-separated networks or addresses whose X-Forwarded-For names a registration's source. Unset: the TCP peer is the source (section 4.1.4).
CONTROLLER_DEPLOY_PROFILEunsetThe deploy profile. The issuer does not start without it.
POD_NAMESPACEthe service account namespace fileThe controller namespace, for the issuer key placement check: it must not be the loop, rig, sandbox, crucible-kueue, workload or [buildit] namespace.
CONTROLLER_AUTH_MODEper docs/controller-auth.mdPeople authorization needs native: /oauth/authorize reads a native session only.
CONTROLLER_CLUSTER_LINK_SECRETSunsetA directory with one OAuth client secret file per cluster. Necessary when the profile has [cluster_link].
CONTROLLER_GITHUB_LOGINSunset<github login>=<crucible login> pairs. A GitHub review approval sets the dispatcher only for a listed pair.
CONTROLLER_PUSH_TOKENS, CONTROLLER_CLUSTERS_DIRunsetTogether they decide which clusters are push-only (section 6.7).

The issuer refuses to start when any of these is true:

  • Any other CONTROLLER_AGENT_* or CONTROLLER_PEOPLE_* variable is set, but CONTROLLER_AGENT_ISSUER is not.
  • CONTROLLER_AGENT_ISSUER is set, but there are no agent clusters (no isolated profile with an agent server, and no CONTROLLER_AGENT_SPIRE) and the profile serves people nowhere.
  • CONTROLLER_AGENT_SPIRE is set and the profile is not isolated.
  • A cluster whose SPIRE cannot be discovered does not stop the controller: agents are off there, GET /api/agent-clusters says why, and discovery tries again each minute.
  • CONTROLLER_PEOPLE_CLIENTS_FILE cannot be read, is not a valid [[clients]] list, or names a resources entry that is not a people resource.
  • CONTROLLER_PEOPLE_CIMD_HOSTS is set and lists no host, or an entry that is not a host name.
  • CONTROLLER_PEOPLE_DCR is not one of the values above.
  • CONTROLLER_PEOPLE_TRUSTED_PROXIES has an entry that is not an IP network or address.
  • The controller namespace is unknown, or is a loop, rig, sandbox, crucible-kueue or workload namespace.
  • A value does not parse.

The three client variables apply only when the deploy profile serves people. Without people tokens, the controller does not read them.

9.4 Overrides: deploy profile keys

KeyDefaultWhat it does
[cluster] sandbox_driverkubernetes with a sandbox_namespace, else podmanSet only for sandboxes in the loop namespace under the kubernetes driver. Profile load refuses podman with a sandbox_namespace.
[cluster] sandbox_namespaceunsetThe other half. Unset: sandboxes live in the loop namespace and no agent tokens are issued.
[cluster] sandbox_service_accountcrucible-sandbox with a sandbox namespace, else the loop'sThe account sandbox pods run as. Under isolated gateways it is never the loop's account, which does not exist in the sandbox namespace.
[cluster] sandbox_readers[]namespace/name service accounts that get the controller rights in section 6.6.
[cluster] sandbox_spiffe_socket/spiffe-workload-api/spire-agent.sockThe SPIFFE Workload API socket path that supervisors get through csi.spiffe.io. The controller delivers it only to runs that get agent servers, since the CSI driver runs only where SPIRE does.
[cluster] identity"link"The hub identity rule.
[clusters.<name>] identity"link"A spoke identity rule.
[cluster] platform, [clusters.<name>] platformdetectedkubernetes or openshift, over what is detected: the controller asks each cluster's API server (OpenShift serves security.openshift.io), and a render without cluster access takes crucible deploy render --platform, then this key, then kubernetes. The distribution the hub or a spoke runs. It decides what a render may assume of the cluster (section 11.2). Each cluster's render reads that cluster's own entry: an OpenShift hub dispatching to an OpenShift spoke and a Kubernetes spoke renders each spoke for its own platform, and nothing applies the hub's platform to a spoke. The hub's own platform affects only what is rendered for the hub itself; its ingress and deploy are the Argo chart's.
cluster nameshub, each [clusters.<name>], [kueue]/[buildit] clusters and the cluster in CONTROLLER_AGENT_SPIRE are DNS-1123 labels, all checked by one function (check_cluster_name, crucible/src/deploy/profile.rs).
[cluster] aws_sandbox_role_arnunsetThe AWS role that the gateway pod assumes for sandboxes.
[kueue] imagerequired by deploy render --kueueThe crucible-kueue image. The controller does not read it.
[kueue] clusters or cluster["hub"]The clusters where the server runs. Runs on these clusters get crucible-kueue access.
[kueue] namespacecrucible-kueueThe server namespace.
[kueue] workload_namespaces (or workload_namespace for one)required, not emptyThe namespaces crucible-kueue tokens name, the first as the namespace claim, and the only namespaces where the server account has workload verbs. Each gets its Namespace, RoleBinding and --namespace.
[kueue] run_jwks_uri<CONTROLLER_AGENT_ISSUER>/.well-known/jwks.jsonFor a server that cannot reach the issuer address.
[kueue] kube_authzopenshift on an OpenShift cluster, token elsewhereopenshift (the cluster's Group objects) or token (the token's kube_groups): where groups come from.
[kueue] cluster_domaincluster.localThe cluster DNS domain of the Service name.
[kueue] pull_secrets[]name or name=registry entries for --pull-secret.
[kueue] peoplefalsetrue serves people's MCP clients too: each cluster's server is a people resource, and the render adds --person-issuer with the controller's agent issuer.
[kueue] people_urlseach cluster's Service URL{ <cluster> = "<url>" }: where people reach the server on a cluster (a route, a port-forward). A person's token names its cluster by this URL, so people on several clusters needs one per cluster; profile load refuses two clusters at one URL. The render serves it as another --resource and --allowed-host.
[buildit] clusters or cluster["hub"]The clusters whose runs get buildit.
[buildit] namespacecrucible-buildsWhere builder pods run. Profile load refuses the loop, rig, sandbox, crucible-kueue and workload namespaces.
[buildit] egressTCP 443 to 0.0.0.0/0 and ::/0[{ cidr, ports = [443], except = [] }, ...]: where builder pods may connect besides DNS (section 8.4).
[buildit] run_jwks_uri<CONTROLLER_AGENT_ISSUER>/.well-known/jwks.jsonFor a loop pod that cannot reach the issuer address.
[buildit] push_repounsetThe one repository for push. Unset: push is refused.
[buildit] quota{ pods = "16", "requests.nvidia.com/gpu" = "0", "requests.amd.com/gpu" = "0" }The builder namespace's ResourceQuota hard limits.
[buildit.cache] clustersthe hubThe dispatch clusters the shared cache is deployed on and serves, each one of [buildit] clusters.
[buildit.cache] token_secretrequired with [buildit.cache]The loop-namespace Secret whose token key is the shared cache's write token (8.7).
[buildit.cache] writetrueBuilds get the token and may write to the shared cache. false: they read only.
[buildit.cache] size_gib200The cache's claim size; it keeps 90% of it before dropping the least recently read blobs.
[buildit.cache] storage_class, imagethe cluster's, [buildit] image or else the loop imageThe claim's StorageClass and the image buildit cache runs from.
[buildit] imagethe loop image's builditAn image holding /usr/local/bin/buildit that loop pods run instead (8.1).
[buildit] urlhttp://host.openshell.internal:8848/mcpThe URL sandboxes reach buildit at, the audience of its tokens. It listens on this port in the loop pod, and the loop pod's ingress opens it.
[[agent_servers]]noneAnother MCP server that takes agent tokens: name (its .mcp.json key, a DNS label), url, scopes (default none), clusters (default ["hub"]), requires_kube_user (default false) and namespaces (default none). It needs only this entry. Names are unique and never broker, and no two servers share a URL on a cluster.
[secrets] push_authfileunsetThe Secret that holds the push authfile, mounted in the loop pod.
[cluster_link] external_urlCONTROLLER_AGENT_ISSUERThe controller URL as browsers reach it. https, or http to loopback. Without either, cluster links refuse to start.
[cluster_link] client_idcrucible-controllerThe OAuthClient name.
[cluster_link] openshiftrequiredThe link clusters that link through their OAuth server.
[cluster_link] access_token_max_age_secs300The lifetime of the user:info token.

9.5 Loop pod and gateway pod environment

The controller sets these. Do not set them by hand.

VariableSet whenRead by
CRUCIBLE_AGENT_SERVERSThe run gets at least one server: one the profile describes on the run's cluster ([kueue], [buildit], [[agent_servers]]), and the issuer trusts the cluster's SPIREengine: a JSON list of {name, url, scopes, transport}; one provider, egress entry and .mcp.json entry each, and a start for a loop-pod transport
CRUCIBLE_AGENT_TOKEN_ENDPOINTwith CRUCIBLE_AGENT_SERVERSengine: token-grant providers, and the bind decision
CRUCIBLE_AGENT_SVID_AUDIENCEwith CRUCIBLE_AGENT_SERVERSengine: token_grant.jwt_svid_audience
CRUCIBLE_OPENSHELL_GATEWAY_URLIsolated profileengine: remote gateway
CRUCIBLE_OPENSHELL_GATEWAY_TLS_DIRIsolated profileengine: client material
CRUCIBLE_GATEWAY_*Isolated profile, gateway pod onlycrucible openshell-gateway

The engine refuses a partial set: servers without the token endpoint or the SVID audience fail the turn, and so does a list that does not parse or a server URL without an http(s) host and port (AgentServersError, crucible/src/openshell/agent_servers.rs). No list, or an empty one, gives the sandbox no provider, no egress entry and no .mcp.json entry. A granted server named like the turn's broker or one of its [mcp] servers fails the turn with an error naming it, rather than one silently replacing the other in .mcp.json (AgentServers::check_names).

9.6 crucible-kueue flags

Each flag also has a CRUCIBLE_KUEUE_* environment variable (crucible-kueue/src/bin/crucible-kueue.rs:16).

FlagDefaultWhat it does
--bind0.0.0.0:8850Listen address.
--contextunsetA kubeconfig context; unset uses in-cluster config.
--allowed-hostloopback onlyExtra Host values, for example the Service DNS name.
--run-issuerat least one of the two issuer flags<issuer>=<jwks uri> of run tokens.
--person-issuerunset<issuer>=<jwks uri> of people tokens. Needs --resource and --kube-authz.
--resourcerequiredThe canonical MCP URLs; aud must name one.
--clusterrequired with --run-issuerThe cluster this server acts on. A token's kube_cluster must name it. Without run issuers, unset accepts people's tokens for any cluster.
--jwks-caunsetExtra CA bundle for JWKS hosts.
--insecure-jwks-httpfalseAllows plain-http JWKS off loopback.
--tool-policyunsetThe CEL tool policy file.
--kube-authzunsetopenshift or token. Unset: every access check is refused, and people tokens are refused.
--namespacediscoverThe served namespaces.
--pull-secretnoneDeclared pull secrets.
--default-active-deadline86400Seconds, or none.
--default-ttl-after-finished3600Seconds, or none.
--suspendtrueCreate batch kinds suspended.
--accelerator-resource, --accelerator-product-labelNVIDIA, AMD, Habana, TPU namesWhat hardware_describe counts.
other generic flagssection 7.12

crucible deploy render --kueue --issuer <CONTROLLER_AGENT_ISSUER> [--kueue-cluster <name>] renders the server for one [kueue] clusters entry (the only one, or the one named) with --run-issuer, --resource, --allowed-host, --kube-authz, --cluster <name>, --namespace for each workload namespace and the pull secrets (crucible/src/deploy/kueue.rs:123). With [kueue] people = true, it also renders --person-issuer with the same issuer and JWKS URI as --run-issuer (kueue.rs:141).

Note: People tokens and agent tokens have the same iss. A server that trusts the issuer as both kinds treats a token with act as a run and a token without act as a person.

9.7 buildit flags

FlagDefaultWhat it does
--bind (MCP_BIND)0.0.0.0:8849The engine sets 0.0.0.0:8848.
--run-issuer, --resource, --jwks-ca, --insecure-jwks-httpAs for crucible-kueue.
--tool-policyunsetThe engine sets the shipped policy.
--run-idunsetThe only run whose tokens are accepted. Unset accepts any run.
--namespacethe client namespaceWhere builder pods go.
--push-repo (MCP_PUSH_REPO)unsetNeeds --push-authfile.
--push-authfile (MCP_PUSH_AUTHFILE)unsetNeeds --push-repo.
--pod-deadline7200Builder pod activeDeadlineSeconds.
--max-builds4Live builder pods per sandbox.

buildit also accepts an older tokens file. buildit never reads a compact JWT as a tokens-file token.

9.8 What stays inert

With no agent or people configuration, the controller does none of the work in this document. A test boots the controller with a deploy profile and no agent configuration and checks this (crucible-controller/src/lib.rs).

AreaInert whenDetail
/oauth/token, /.well-known/jwks.json, /.well-known/oauth-authorization-serverCONTROLLER_AGENT_ISSUER unsetNot routed (crucible-controller/src/lib.rs:304)
/api/sandbox-bindings/runs/{run_id}sameNot routed
/oauth/authorizethe profile serves people nowhereNot routed (crucible-controller/src/lib.rs:138)
/oauth/registerissuer unsetNot routed. With the issuer set and people tokens or registration off, it answers 404 invalid_request
Metadata document fetchesCONTROLLER_PEOPLE_CIMD_HOSTS unsetNo outbound request; every URL client_id is refused
Identity routes: cluster identities, link, callback, consent, MCP sign-ins, hub overrideno agent tokens, no people tokens and no [cluster_link]Not routed, not in the OpenAPI document (crucible-controller/src/api/mod.rs:672)
/api/agent-tools, /api/agent-clusters, /api/prefs/noticesno agent tokens: no issuer, or no agent clusters (ControllerCfg::issues_agent_tokens)Not routed
Gateway sweepprofile not isolatedNot registered
Grants sweep, registration sweepno people tokens; the registration sweep also when registration is offNot started
Loop pod env and token-grant providersno agent tokens, or the run gets no server (section 9.5)Not set, so the engine creates none and binds nothing
issues.dispatch_requested_by, runs.dispatched_byno agent tokensNot written
run_agent_toolsno agent tokens, or the run gets no serverNot written, and not read for run pages
run_sandboxesno bind requestsNot written
people_dynamic_clientsno people tokens, or registration offNot written
UI: defaults banner, run tools panel, consent page, settings panels/api/whoami reports features.agent_tools and features.identities falseNot rendered and not fetched

10. Data model

10.1 Tables and relations

erDiagram
    runs ||--o{ run_sandboxes : "run_id"
    work_pods ||--o{ run_sandboxes : "pod_name"
    people_grants ||--o{ people_refresh_tokens : "grant_id"
    runs {
        TEXT run_id PK
        TEXT dispatched_by
    }
    issues {
        TEXT key PK
        TEXT dispatch_requested_by
    }
    users {
        TEXT sub
        TEXT login
        TEXT kube_user
        TEXT kube_user_override
    }
    run_sandboxes {
        TEXT sandbox_id PK
        TEXT run_id FK
        TEXT pod_name FK
        TEXT bound_at
        TEXT sandbox_name
        TEXT workdir
    }
    kube_identity_links {
        TEXT sub PK
        TEXT cluster PK
        TEXT kube_user
        TEXT source
        TEXT linked_at
    }
    people_codes {
        BYTEA key PK
        TEXT sub
        JSONB grant_json
        BIGINT expires_at
    }
    people_grants {
        TEXT id PK
        TEXT sub
        TEXT client_id
        TEXT client_name
        TEXT resource
        TEXT cluster
        JSONB scopes
        BIGINT created_at
        BIGINT last_used_at
        BIGINT revoked_at
    }
    people_refresh_tokens {
        BYTEA key PK
        TEXT grant_id FK
        BIGINT issued_at
        BIGINT used_at
    }
    people_dynamic_clients {
        TEXT client_id PK
        TEXT client_name
        JSONB redirect_uris
        BIGINT created_at
        BIGINT last_used_at
    }
    run_agent_tools {
        TEXT run_id PK
        TEXT server PK
        TEXT cluster
        TEXT url
        TEXT kube_user
        JSONB scopes
        JSONB limits
        TEXT granted_at
    }

Only the foreign keys in the diagram exist. kube_identity_links, people_* and run_agent_tools reference nothing outside themselves. run_agent_tools is written before the run row exists, so it has no foreign key. people_grants.client_id has no foreign key to people_dynamic_clients: most client IDs are registered names or URLs. The registration sweep deletes the approvals of a dyn- client ID that has no row.

10.2 Migrations

MigrationChangeWritten byRead by
0059_run_sandboxesTable run_sandboxes: sandbox_id (PK), run_id (FK runs, cascade), pod_name (FK work_pods, cascade), bound_at; index on pod_namebindings::bind from the bind route (crucible-controller/src/agent_auth/bindings.rs:150)bindings::resolve at the token endpoint (bindings.rs:239)
0060_user_kube_userColumns users.kube_user and users.kube_user_overridekube_user_override: PUT /api/users/{login}/kube-user. kube_user: nothing nowkube_identity::override_for_sub on the hub (crucible-controller/src/identity/kube_identity.rs:64). kube_user is read by nothing
0061_run_sandbox_namesColumns run_sandboxes.sandbox_name, run_sandboxes.workdir; a unique index on the namethe bind routethe token endpoint, for the sandbox and workdir claims
0062_dispatch_principalColumns issues.dispatch_requested_by, runs.dispatched_byissues::store::request_dispatch (unpark, redispatch, bump, scope, paired approval); runs::store::stamp_dispatcher at run creation, which also clears the requestbindings::resolve (the launcher)
0063_run_sandbox_names_not_globalDrops the unique name indexThe bind transaction checks a name only against live bindings on the same cluster
0064_kube_identity_linksTable kube_identity_links (sub, cluster) PK, kube_user, source, linked_atkube_links::link (paste and OAuth link), kube_links::unlinkcluster_identity::resolve for people tokens, agent tokens, agent tools, settings and consent
0065_people_grantsTables people_codes, people_grants, people_refresh_tokens, with indexes on sub and grant_idPgGrantStore (crucible-controller/src/agent_auth/people/grants.rs)PgGrantStore
0066_run_agent_toolsTable run_agent_tools, PK (run_id, server)agent_tools::grant at dispatch (crucible-controller/src/runs/agent_tools.rs:263); agent_tools::attribute sets kube_user after the run row is committed (agent_tools.rs:283)the run DTO and the issue DTO, for the run tools panel
0067_people_grant_clientsColumns people_grants.client_id and people_grants.client_name, NOT NULL. Rows from before get claude-code and Claude Code. Index on (sub, client_id)PgGrantStore::issue_refreshrotate_refresh (the client check), live (Settings), revoke_client
0068_people_dynamic_clientsTable people_dynamic_clients: client_id (PK), client_name, redirect_uris, created_at, last_used_at; index on last_used_atDcr::register; Dcr::used stamps last_used_at after a successful code or refresh exchange (crucible-controller/src/agent_auth/people/dynamic.rs)Dcr::client at authorize and token, without stamping; Dcr::sweep deletes idle rows

Secrets in people_codes and people_refresh_tokens are stored only as SHA-256. A spent refresh token stays in its row with used_at until its approval goes, so a reuse is recognized.

Note: users.kube_user is no longer written or read. An earlier design learned it passively from cluster tokens. The hub username now lives in kube_identity_links like any other cluster, plus the override. The header comment in migrations/0060_user_kube_user.sql still describes that earlier design. It stays, because sqlx checksums each migration file and an edit would fail databases that already applied it.

11. Operations

11.1 Deploy order

Do these steps in this order. The issuer must not issue agent tokens before gateway isolation is in place.

  1. Build the images from the same commit: the loop image, the controller image and the crucible-kueue image (docker/Containerfile.kueue). The loop image must contain crucible openshell-gateway, openshell-gateway and buildit.
  2. On each cluster that hosts runs, install SPIRE with the OIDC discovery provider. Add a ClusterSPIFFEID for supervisor pods in the sandbox namespace, with jwtTtl: 5m and the template in section 6.1.
  3. In the deploy profile, set sandbox_namespace and sandbox_readers.
  4. Render the loop RBAC with crucible deploy render --manifest <render.toml> --profile <profile>. Apply the ServiceAccount, Roles and bindings on each cluster. They include crucible-spire-reader, which lets sandbox_readers discover the cluster's SPIRE (section 6.9).
  5. With [buildit], create [buildit] namespace, and make sure that step 4 applied the Role and RoleBinding <service account>-buildit there (section 6.6). Builder pods run as that namespace's default service account; on OpenShift bind it to an SCC that admits them.
  6. Render crucible-kueue with crucible deploy render --kueue. Apply it on each cluster in [kueue] clusters. Copy the declared pull secrets into the workload namespace. Create the LocalQueues.
  7. Grant people their queues with ordinary RBAC (section 11.2).
  8. Create the issuer key Secret in the controller namespace. Mount it in each replica.
  9. Set the controller environment (section 9.1, overrides in 9.3). Each cluster an agent server serves has its SPIRE discovered; check GET /api/agent-clusters after the restart. Make sure that loop pods reach the controller at [cluster] ingest_url. Restart the controller.
  10. For people tokens: set CONTROLLER_AUTH_MODE=native and [kueue] people = true (with people_urls where people reach a cluster's server by a route), render crucible-kueue again, and expose it on a Route. To change the clients, set CONTROLLER_PEOPLE_CLIENTS_FILE, CONTROLLER_PEOPLE_CIMD_HOSTS or CONTROLLER_PEOPLE_DCR (section 4.1).
  11. For OAuth links: render crucible deploy render --cluster-link <cluster> --issuer <issuer> and apply it on that cluster with a new secret. Put the same secret in the CONTROLLER_CLUSTER_LINK_SECRETS directory under the cluster name.

To make the issuer key, use this command:

openssl ecparam -genkey -noout -name prime256v1 | openssl pkcs8 -topk8 -nocrypt -out agent-signing.pem

11.2 RBAC per spoke

Section 6.6 lists the gateway, controller and loop rights. crucible deploy render --kueue gives the server account:

  • cluster-wide: clusterqueues and resourceflavors get and list; nodes get and list; subjectaccessreviews create; with openshift, groups.user.openshift.io list (crucible/src/deploy/kueue.rs:262);
  • in the workload namespace only (kueue.rs:295):
    • localqueues: get, list;
    • Kueue workloads: get, list, patch;
    • localqueues/pendingworkloads: get, list;
    • the seven kinds: create, get, list, patch, delete;
    • pods, pods/log, events: get, list.

The workload namespace's labels follow the cluster's platform (section 11.2.1).

Each person, or the launcher of each run, needs RBAC for what they do. A minimum Role for one queue is this:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: team-queue-user
  namespace: team-workloads
rules:
  - apiGroups: [kueue.x-k8s.io]
    resources: [localqueues]
    verbs: [list]
  - apiGroups: [kueue.x-k8s.io]
    resources: [localqueues]
    resourceNames: [team-queue]
    verbs: [get, use]
  - apiGroups: [kueue.x-k8s.io]
    resources: [workloads]
    verbs: [get, list]
  - apiGroups: [batch]
    resources: [jobs]
    verbs: [create, get, update, delete]
  - apiGroups: [""]
    resources: [pods, pods/log]
    verbs: [get, list]

Note: use is a verb that only crucible-kueue asks about. Where a cluster cannot grant it, run the server with --queue-verb get.

11.2.1 OpenShift and plain Kubernetes

Each cluster's distribution is detected: the controller asks its API server whether it serves security.openshift.io. [cluster] platform for the hub and [clusters.<name>] platform for a spoke override that, and a render without cluster access (crucible deploy render --kueue) takes --platform, then the key, then kubernetes. A render for a cluster reads only that cluster's entry, so one OpenShift hub can dispatch to an OpenShift spoke and a Kubernetes spoke, each rendered for itself; the hub's own platform affects only what is rendered for the hub, and the Argo chart handles the hub's ingress and deploy.

platform = "openshift" (tested on OpenShift 4.22)platform = "kubernetes" (tested on Kubernetes 1.35)
Pod admissionSCCs, which the operator grants: the sandbox account an SCC for OpenShell's supervisor, the loop account one that gives its pod a writable home, and the buildit namespace's default account one for rootless buildah (an SCC broad enough for buildah may allow privileged pods; the builder admission policy is the bound)Pod Security labels, which the operator sets: loop, rig, sandbox and workload namespaces baseline, crucible-kueue's namespace restricted, the buildit namespace privileged, with the builder admission policy as the guard
crucible-kueue workload namespace (rendered)pod-security.kubernetes.io/enforce: restricted (SCCs fill in its fields) and kueue.openshift.io/managed: "true"enforce: baseline; no Kueue label: upstream Kueue manages what its managedJobsNamespaceSelector admits
crucible-kueue kube_authz defaultopenshift: the user's Group objects, plus system:authenticated and system:authenticated:oauthtoken: the token's kube_groups claim, plus system:authenticated
Kubernetes usernamesOAuth usernames (alice)what the API server's OIDC mapping makes them, such as an email; labels carry it in label-safe form, its safe characters and a hash
Cluster-token bearer auth (CONTROLLER_KUBE_USER_AUTH)SelfSubjectReviewSelfSubjectReview
Cluster linksTokenReview paste, or the OpenShift OAuth flow ([cluster_link], an OAuthClient)TokenReview paste only
SPIREZero Trust Workload Identity Manager; its OIDC discovery Service over https on 443upstream SPIRE Helm chart; its discovery Service over http on 80 (http:// before the namespace in CONTROLLER_AGENT_SPIRE); a ClusterSPIFFEID must name spire-controller-manager's className when it watches no classless entries
Builder admission policyenforced (OpenShift 4.17+)enforced (1.30+)

crucible-kueue's own pod sets every field restricted asks for, RuntimeDefault seccomp included, so its namespace can enforce restricted on either platform. API discovery tells the platforms apart by security.openshift.io (the SCC API); config.openshift.io is no evidence, as other distributions serve it.

11.3 SPIRE requirements

  • The supervisor JWT-SVID must carry iat, and exp minus iat must be at most 900 seconds. The upstream SPIRE chart issues 1-hour JWT-SVIDs by default. Set jwtTtl: 5m on the supervisors' ClusterSPIFFEID.
  • The aud of the SVID must be exactly the issuer identifier.
  • The SPIFFE ID must be <gateway prefix><gateway id>/sandbox/<sandbox id>. Scope the ClusterSPIFFEID to the sandbox namespace.
  • The discovery provider must serve /keys. The controller reads it through the API server Service proxy. Discovery reads it at the platform's default; an install elsewhere is named in CONTROLLER_AGENT_SPIRE as <namespace>/<service>:<port>, with http:// first when the Service serves plain http.
  • Each cluster needs its own SVID issuer: a token request is matched to its cluster by the SVID's iss.

11.4 Rollback

To disable the feature with no data change, do these steps:

  1. Unset CONTROLLER_AGENT_ISSUER and CONTROLLER_AGENT_SPIRE.
  2. Restart the controller. The routes, tasks, env and writes in section 9.8 stop.
  3. Remove [kueue] and [buildit] from the profile if new runs must not get the servers.
  4. Delete the gateway objects that remain in the sandbox namespace by label crucible.dev/role=openshell-gateway, if the sweep no longer runs.

To roll the controller image back to a build without migrations 0059 to 0068, also revert the schema. The migrator does not ignore applied migrations that it does not know (crucible-controller/src/lib.rs:417), so an older controller does not start on the new schema. To roll back only to a build with 0066 and without 0067 and 0068, run the second script.

Warning: This SQL deletes the bind records, links, approvals, client registrations and agent tools records. Back up the database first. Stop every controller replica before you run it.

BEGIN;
DROP TABLE people_dynamic_clients;
DROP TABLE run_agent_tools;
DROP TABLE people_refresh_tokens;
DROP TABLE people_grants;
DROP TABLE people_codes;
DROP TABLE kube_identity_links;
ALTER TABLE runs DROP COLUMN dispatched_by;
ALTER TABLE issues DROP COLUMN dispatch_requested_by;
DROP TABLE run_sandboxes;
ALTER TABLE users DROP COLUMN kube_user_override;
ALTER TABLE users DROP COLUMN kube_user;
DELETE FROM _sqlx_migrations WHERE version BETWEEN 59 AND 68;
COMMIT;

The second script keeps the approvals of claude-code only. An older controller knows no other client.

BEGIN;
DROP TABLE people_dynamic_clients;
DELETE FROM people_grants WHERE client_id <> 'claude-code';
DROP INDEX people_grants_sub_client;
ALTER TABLE people_grants DROP COLUMN client_name;
ALTER TABLE people_grants DROP COLUMN client_id;
DELETE FROM people_codes;
DELETE FROM _sqlx_migrations WHERE version IN (67, 68);
COMMIT;
ComponentMessageWhat it tells you
controllerissuing agent access tokensThe issuer started; lists the SPIRE clusters and audiences
controllerissuing access tokens to peoplePeople tokens are on
controlleragent token issuedFields sandbox, spiffe_id, run_id, sub, kube_user, aud, scope
controlleragent token refusedField reason gives the cause the client does not see
controlleragent token minted without kube_userA non-kueue audience for a launcher with no username
controllersandbox boundA bind record was written
controllersandbox bind refused, sandbox bind could not reach the clusterA bind was refused, or the cluster did not answer
controllerthe bound sandbox could not be owned by its gateway podThe owner reference failed; teardown still deletes by label
controllerauthorization request refusedField reason, or error and reason when the error went to the redirect URI
controllerauthorization code issuedFields login, client_id, resource
controllerperson token issuedField grant is authorization_code or refresh_token; field client_id
controllerperson token refusedField reason
controllerrefresh token reused; approval revokedReuse detection fired
controllerrefresh token presented by another client; approval revokedFields issued and presented name the two client IDs
controllerdynamic client registered, client registration refusedA registration at /oauth/register, with client_id and source, or the reason
controllerclient metadata document fetchedA fetch of a metadata document, with client_id and ttl_secs
controlleridle client registrations sweptThe sweep deleted registrations idle for 30 days
controllermcp clients signed outA sign-out from Settings; client_id is absent for a sign-out of every client
controllerkubernetes username linkedA link was written
controllera failed launch left its gateway, deleting the run's gateway failedThe sweep removes the rest
controllergateway sweep: removed an orphaned gatewayThe sweep found a gateway with no live run
enginesandbox bind failed (its agent token requests will be refused)The sandbox gets no tokens
crucible-kueuetoken refused, caller authenticatedAuthentication result, with sub, presenter, run, namespace
crucible-kueuetoken identifies no caller this server servesNo kube_user, a person without a reviewer, or run_id in a people token
crucible-kueuerequest refusedA guard refusal, with the rule
crucible-kueuesubject access review (debug)Each review with user, verb, resource and decision
crucible-kueueworkload submittedA create succeeded
builditcaller authenticated, token refused, pushedAuthentication and push results

11.6 Common failures

SymptomCauseFix
The gateway pod exits at once; its log names an unknown subcommand or an absent openshell-gatewayThe loop image predates this feature and lacks crucible openshell-gatewayBuild the loop image from the same commit as the controller
agent token refused with reason about the assertion lifetimeSPIRE issues 1-hour JWT-SVIDsSet jwtTtl: 5m on the supervisors' ClusterSPIFFEID
Dispatch fails with PushOnlyClusterThe hub has a push token and no client for the clusterPut a kubeconfig for the cluster in CONTROLLER_CLUSTERS_DIR, or use a profile that is not isolated for it
The Job is admitted but its pods are in ImagePullBackOffThe declared pull secret does not exist in the workload namespaceCopy the Secret into the workload namespace with the name in [kueue] pull_secrets
The agent turn fails at the first model callThe launch named no --model, and the endpoint does not serve the provider's default_modelLaunch with --provider <name> --model <model>, or fix the provider's default model
On OpenShift the loop pod fails to stage the gateway client materialThe loop pod runs with an arbitrary UID and HOME=/Bind the loop service account to the SCC that gives the loop pod a writable home
Every token request is invalid_grant with reason "bound to no run"The loop pod never bound its sandboxes: no ingest_url in the profile, or no route to the controllerSet [cluster] ingest_url to an address loop pods reach; on a spoke expose /api/sandbox-bindings and /oauth/token
invalid_grant with a reason about kube_userThe launcher has no username on the run's clusterLink it in Settings, set a claim rule, or set the hub override
The first isolated run parks with a 403 on the server TLS SecretThe controller identity lacks pods/finalizers update in the sandbox namespaceRe-render and apply the loop RBAC
server_error with SPIRE keys unavailableNo services/proxy get in the SPIRE namespace, or a SPIRE outside its platform's default namespace with no CONTROLLER_AGENT_SPIRE entryGrant the right; check the namespace, name, port and scheme
A people token gets 403 from crucible-kueueThe server trusts the issuer only as --run-issuerAdd the same issuer as --person-issuer
A refresh suddenly returns invalid_grantReuse detection after a retried refresh, an unlinked username, or a refresh token presented as another clientApprove the client again; check the controller log for the reason
A refresh returns invalid_clientThe client registry no longer lists the client, the metadata host is no longer allowlisted, or the registration was idle for 30 daysRegister the client again, or sign in under a registered client
The authorize page says that the redirect URI is not registered for claude-codepi signs in with claude-code and a 127.0.0.1 callback. claude-code registers only localhostAdd the server to pi again without OAuth options, or with --oauth-client-id pi
Codex stops with "Dynamic client registration not supported"CONTROLLER_PEOPLE_DCR=false, and Codex has no client idPass --oauth-client-id codex, or turn registration on
The authorize page refuses an https:// client IDCONTROLLER_PEOPLE_CIMD_HOSTS is unset or does not list the host, or the document failed a checkAdd the host to CONTROLLER_PEOPLE_CIMD_HOSTS; check the page for the reason
POST /oauth/register returns 429 for many peopleEvery registration counts against the router address: CONTROLLER_PEOPLE_TRUSTED_PROXIES does not cover the router, or the router does not append X-Forwarded-ForSet CONTROLLER_PEOPLE_TRUSTED_PROXIES to the network the router connects from, and make the router append X-Forwarded-For

12. Tests

12.1 Unit tests

  • crucible-mcp-auth tests use real ES256 keys and a JWKS server on loopback (crucible-mcp-auth/src/testing/mod.rs). They cover algorithms, kid and issuer checks, audience, leeway, scope challenges, the guard decisions and the CEL policy.
  • crucible-kueue tests cover the manifest allowlist, normalization, plan hashes, pull secrets, the claim map, access checks for each tool, and task ids. crucible-kueue/tests/task_auth.rs drives the HTTP app in process.
  • buildit tests cover token callers, push targets, context paths and sanitization.
  • The people client tests cover the client ID forms, the redirect rules of each built-in client, the registry file (crucible-controller/src/agent_auth/people/clients.rs), and the registration checks, name cleanup, source address and rate limiter (people/dynamic.rs).
  • The metadata document tests serve documents on loopback. They cover the allowlist, the document checks, the timeout, the cache lifetime, and names that resolve to special-use addresses (people/cimd.rs).
  • The engine tests cover the token-grant profiles, the partial-env refusals and the bind decision.
  • The render tests cover the gateway objects and the network policies. They also prove that the loop service account has no verbs in the sandbox namespace under an isolated profile.
  • The UI has unit tests for each view (crucible-controller/ui/src/pages/*View.test.ts) and Playwright tests for consent and settings (crucible-controller/ui/e2e/consent.spec.ts, settings.spec.ts).

12.2 Postgres tests

The controller tests that touch the ledger are #[sqlx::test] tests. They need a live Postgres at DATABASE_URL; each test gets a fresh database. just dev-pg starts one on port 55432. These tests cover these areas:

  • the token endpoint against real bind records, and the bind route;
  • the people grants: single-use codes, rotation, reuse, idle and absolute expiry, and a code or refresh token from one replica that another replica redeems;
  • the client binding: a refresh as another client revokes the approval, and a sign-out of one client keeps the other clients;
  • registrations: the issued id, the rate limit and cap, and the idle expiry that deletes the approvals;
  • cluster links, consent, the hub override, dispatcher stamps and the agent tools record.

12.3 Cluster tests

These tests are #[ignore] and need a cluster. Their headers list the environment they read.

TestWhat it proves
crucible-controller/tests/kueue_oidc_e2e.rsA real JWT-SVID becomes an agent token at the controller, and the token drives crucible-kueue with RBAC as kube_user
crucible-controller/tests/kueue_gateway_e2e.rsThe controller starts a real gateway pod; a supervisor it creates gets a gateway-keyed SPIFFE ID; the token works; teardown removes everything. Prepare with just gateway-e2e-setup
crucible-kueue/tests/cluster_e2e.rsThe server against a real Kueue
buildit/tests/oidc_e2e.rsbuildit with real tokens

12.4 The full e2e harness

just kueue-e2e runs scripts/kueue-e2e.sh. A controller from this checkout runs in the cluster with its own Postgres. It dispatches e2e/kueue/pack with an isolated gateway and a crucible-kueue server of its own. The agent writes a program that prints a nonce, builds and pushes it with buildit, and runs it as a Job through crucible-kueue. The script then checks:

  • the controller issued buildit and crucible-kueue tokens to a SPIFFE ID under this cluster's gateway prefix, for the launcher's kube_user and this run;
  • the Job carries the run, launcher and queue labels, and uses only the e2e pull secret;
  • the Job runs an image pushed by digest, succeeded, and printed the nonce;
  • the agent's whoami named the same run and kube_user;
  • no kueue token appeared in the loop pod env, its Secrets or any log;
  • the builder admission policy refuses a privileged pod in the buildit namespace.

It runs on OpenShift and plain Kubernetes alike. It detects the platform (security.openshift.io served or not, or PLATFORM) and derives the rest from it, each value overridable: SPIRE's namespace, discovery Service, trust domain and JWT issuer (the Zero Trust Workload Identity Manager's resources, or the upstream spire-server ConfigMap), the ClusterSPIFFEID class, the profile's platform, the served Kueue API version, and pod admission (SCC role bindings, or Pod Security labels). A kubeconfig without a static token has its exec credential plugin run for the launch token. just kueue-e2e-cluster <name> reads a cluster's values from e2e/kueue/clusters/<name>.env, which git ignores; openshift.env.example and kubernetes.env.example show what each platform needs, and the script header lists every value.

PACK=<dir> (a pack argument to the recipes) launches another pack instead of the smoke pack, bounded by MAX_COST, MAX_TIME and TIMEOUT_SECS, with PARAMS as its --params. Its prompts get the same placeholders filled. The Job, nonce and whoami checks belong to the smoke pack; another pack passes when its run left at least one Job in the workload namespace and the plan's RESULT_TASK (default verify) ended pass, and the harness prints that task's output from the graph API either way. Every pack gets the token issuance, loop isolation and leak checks.

The harness runs beside a live deployment on the same cluster:

  • It uses its own namespaces, which carry the label crucible.dev/e2e=kueue.
  • It renames each cluster-scoped object with the suffix -kueue-e2e and labels it.
  • Its ClusterSPIFFEID selects only its own sandbox namespace.
  • It refuses to start when one of its namespaces exists without the label.
  • Teardown deletes only by the label. KEEP=1 keeps everything.

Note: A launch with a cluster token records no users row. The harness inserts the launcher's users row and a hub kube_identity_links row, as a login and a link would.

13. Multitenancy later

No API, token, configuration format or SPIFFE ID assumes a single tenant. Teams become a data change: nothing below needs a new shape, only values per team. Nothing here writes a team today, and every one of these defaults to the single-tenant behaviour.

13.1 What is tenant-ready

ShapeToday's valueWhere
Token needs per audienceEach audience carries requires_kube_user and namespaces, filled from the profile's server descriptions; the issuer names no serverAudience::described_by, DeployProfile::agent_servers
Namespaces as a setTokens carry namespaces (the first is also namespace); crucible-kueue confines a caller to the claim within the namespaces it serves; [kueue] workload_namespaces is a listIdentity::act_in, crucible-kueue/src/caller.rs
Open server list[kueue], [buildit] and any [[agent_servers]] reach the loop pod as one CRUCIBLE_AGENT_SERVERS list keyed by ServerName; buildit's URL is configurationcrucible-contract/src/agent_servers.rs
Principal-shaped identityIssuance and grant records resolve an authz::model::Principal; a person through their sign-in, a run through its dispatcher; groups and teams are refused with a typed errorcluster_identity::for_principal
Tenant in the SPIFFE IDA gateway prefix may hold one {tenant} segment; the default has noneGatewayPrefix, section 6.1
Cluster bindingAgent audiences may name a cluster, tokens carry kube_cluster, and a server started with --cluster refuses another'ssection 2.2
One cluster-name ruleProfile spokes, server tables and every controller setting use check_cluster_namecrucible/src/deploy/profile.rs
Several servers per clustercrucible-kueue's managed-by value and field manager come from --managersection 7.12

13.2 What a team rollout still adds

  • A team per run and per person. runs, run_agent_tools and people_grants carry no team. The controller already knows teams (authz::model::Principal::Team); dispatch would record the run's team and the issuer would mint it as a team claim (issuer.rs, people.rs). The tool policy sees every claim, so CEL rules can use it at once.
  • Values per team. Server descriptions, and so audiences' namespaces, [buildit] push_repo and audience scopes, would be looked up by team as well as by URL and cluster. The shapes they fill are already lists in tokens and in CRUCIBLE_AGENT_SERVERS.
  • A team's own identity. A Team arm in for_principal resolving the team's account on each cluster, with storage for those accounts (the one schema change).
  • A tenant in each sandbox's SPIFFE ID. The gateway pod would carry a tenant label, the ClusterSPIFFEID would put it before gateway, and the issuer would check the tenant it parses against the run's team.
  • People. Client registry entries or resources per team, and identity rules per group, so one cluster can trust SSO usernames for some teams and links for others.

The ceiling stays the same in each case: a token can do at most what its kube_user can do.

13.3 No replay tracking for client assertions

The token endpoint does not remember the JWT-SVID assertions it accepts (crucible-controller/src/agent_auth/issuer.rs, MAX_SVID_LIFETIME_SECS), and it should not:

  • The SPIRE agent caches JWT-SVIDs per SPIFFE ID and audience, and returns the cached one until it nears expiry (FetchJWTSVID, pkg/agent/manager/manager.go in spiffe/spire). A supervisor that exchanges for several MCP audiences, or refreshes a token, presents the same assertion again. Refusing a repeat would refuse those exchanges.
  • SPIRE sets jti on a JWT-SVID only when the registration entry opts in (jwt_svid_include_jti, which also bypasses that cache; BuildWorkloadJWTSVIDClaims, pkg/server/credtemplate/builder.go). The JWT-SVID specification does not require it. Keying replays on jti would refuse every assertion from a default entry.
  • The assertion lives at most 900 seconds (SPIRE's default JWT-SVID TTL is 5 minutes), names only this issuer as its audience, and is fetched from the Workload API socket that the OpenShell driver hands to the sandbox supervisor (provider_spiffe_workload_api_socket_path), not to the agent it sandboxes.

Crucible implementation contract (frozen interface)

This is the specification of the interface between the engine (crucible) and a domain. The engine implements it, and a domain satisfies it. A minimal fake domain (examples/counter/) tests it end to end without EPP and without a cluster.

Concept: The loop in one read. Trust line (engine hands the agent a World, never a Judge): ADR-0001.

Contract status: NORMATIVE. Words like must / exactly one are binding. The engine and every domain (EPP included) conform to this; tests assert it.


1. The manifest (crucible.toml)

The engine reads exactly one manifest per run. Default path ./crucible.toml (override --manifest <path>). The manifest directory = dirname(manifest); it anchors all config-relative paths below.

edition = 2                                   # pack contract edition; absent = 1 (§3)

[repo]
# Exactly one of url|path. The upstream the agent edits a checkout of. A playbook may omit the
# whole table: its workspace is then an empty dir the injects populate.
url  = "https://github.com/owner/name.git"   # OR
path = "."                                    # local path, relative to manifest dir
ref  = "main"                                 # optional; default: clone default branch

[workspace]
dir       = "workspace"                       # checkout dir, relative to manifest dir. default "workspace"
setup_cmd = "git clone ... && git checkout"   # optional; default: engine git clone+checkout of [repo]

# Frozen-judge / fixture injection (optional). After setup, the engine copies each `src` (relative
# to the manifest dir, a baked artifact outside the clone target, so the agent has no pre-clone
# copy) to `dst` (relative to the workspace), then commits the result as the baseline. A `frozen`
# inject (default true) is ALSO re-copied before every scored measure and before every plan task,
# so a candidate can't edit the gate (a T1 scoring harness, a seeded regression test) to game it;
# set `frozen = false` for a one-time fixture the agent may then edit. This is the generic
# alternative to hand-chaining an `install` into `setup_cmd`.
#
# A string entry is a frozen copy to the same path. A directory or a glob (`*`, `?`, `[`) expands
# to every regular file under it, sorted; it must stay under the manifest dir and must match
# something. Use the table form to rename, to unfreeze, or to reach outside the manifest dir.
inject = [
  "measure.sh",                                                                    # -> measure.sh, frozen
  "tools/*.py",                                                                    # every script, frozen
  { src = "judges/1489/judge_harness_test.go", dst = "pkg/.../judge_harness_test.go" },
  { src = "fixtures/seed.json", dst = "testdata/seed.json", frozen = false },
]

[agent]
model         = "claude-opus-4-6"             # default engine constant
method_prompt = "method.md"                   # manifest-relative file; template, {{GOAL}}/{{STATUS}}/{{STEER}}
goal          = "raise the score"             # OR goal_file = "goals/x.md" (manifest-relative)
toolbox_dir   = "commands"                    # optional; copied into <workspace>/.claude/skills
backend       = "local"                       # local | openshell | command   (see §6)
sandbox_image = "ghcr.io/<org>/<domain>-sandbox:<tag>"  # openshell backend only
allow_unverified_image = false            # launch on an image the controller's catalog cannot vouch for

[agent.requires]                          # what the sandbox image must provide; the controller refuses a launch otherwise
"toolchain.go" = ">=1.25"

[agent.prefers]                           # ranks compatible images in the controller's picker
"toolchain.go" = ">=1.26"

[agent.resources]                         # openshell backend only: what the sandbox is scheduled with
gpus   = 1                                # nvidia.com/gpu; the deploy profile's [cluster.gpu_sandbox] says where
cpu    = "8"                              # Kubernetes quantities, requested and limited alike
memory = "32Gi"
node_selector = { "nvidia.com/gpu.product" = "NVIDIA-H100-80GB-HBM3" }  # node labels, passed to the pod as written
agent_cmd     = "..."                          # command backend only (§6)
[agent.env]                                   # injected into the agent process (creds, Vertex, etc.)
ANTHROPIC_VERTEX_PROJECT_ID = "my-gcp-project"

[[agent.tool_plugins]]                        # optional; absent = repeat_guard(25), classify, tool_stats
plugin = "repeat_guard"                       # see tool-plugins.md
limit  = 25

[judge]
measure_cmd = "./measure.sh"                  # REQUIRED. §3. Any executable, any language.
direction   = "lower"                         # lower | higher. REQUIRED.
tiebreak_direction = "lower"                  # optional; direction of the `tiebreak` scalar (§4). default: direction
objective   = "score"                         # display label (the old `gate` name). default "score"
env         = ["KUBECONFIG", "EPP_ENDPOINT?"] # edition 2: engine env measure_cmd receives (§3); NAME? only when set

[judge.selftest]                              # optional (ADR-0012). Runs in `crucible check`, never in a loop iteration.
good_cmd = "..."                              # stages a known-good config in the workspace
bad_cmd  = "..."                              # stages a known-bad config in the workspace
runs     = 1                                  # measurements averaged per control. default 1

[world]
# All three omitted  →  GitWorld (tree-only reversibility; the 80% case).
apply_cmd    = "..."                          # optional. §3.
snapshot_cmd = "..."                          # optional. §3.
restore_cmd  = "..."                          # optional. §3.
env          = ["KUBECONFIG"]                 # edition 2: engine env the three commands receive (§3); NAME? only when set

[search]                                      # optional (ADR-0010). Absent or wide=0 → pure-deep, the default.
wide       = 0                                # u32. N parallel propose turns before the deep loop. default 0
approaches = ["...", "..."]                   # REQUIRED when wide > 0: one distinct approach string per candidate slot
policy     = "top-k"                          # only v1 policy. default "top-k"
policy_k   = 1                                # how many wide-round winners seed the deep loop. default 1, must be in 1..=wide

Rules:

  • Required: [agent]. A scored run also requires [repo] (url xor path), [judge].measure_cmd, and [judge].direction. A playbook can omit [repo]. A manifest without [judge] is a task (the task lane).

  • [world] with no commands ⇒ GitWorld (§5). Any command given ⇒ CommandWorld (§5), which still owns git memory and layers the given commands on top.

  • [judge.selftest], if present, requires both good_cmd and bad_cmd (a self-test that only stages one side isn't a control); runs must be >= 1.

  • [search], if present with wide > 0, requires approaches.len() >= wide (hard error, diversity is engineered, not auto-generated) and policy_k in 1..=wide.

  • Edition. edition is 1 when absent; 1 and 2 are the editions this engine runs, and any other value fails the parse naming them. An env list in [judge], [world], or [workflow] needs edition 2 and fails the parse of an edition-1 manifest (§3).

  • Unknown keys are an error (typo protection), not silently ignored.

  • Frozen loading (load_frozen). When the manifest file lives inside the workspace it targets (the BYO on-ramp: crucible init scaffolds [repo] path = "."), the engine parses it from the workspace's pristine base commit, not the current working tree (so an in-flight agent edit to crucible.toml can't retarget its own gate mid-run). Before any base commit exists (the very first run), it hard-warns and trusts the working tree for that run only; later runs freeze to the base commit. A manifest that lives outside the workspace (any out-of-workspace pack) is unaffected.

  • Variants. One manifest can carry per-environment variants as [variant.<name>] tables:

    [agent]
    backend = "openshell"
    
    [variant.offline.agent]
    backend = "command"
    agent_cmd = "./stand-in.sh"
    

    plan run --variant offline, check --variant offline and deploy render --playbook --variant offline read the variant: each top-level table it names replaces the base table of the same name whole ([variant.offline.agent] replaces all of [agent]; nothing is merged below the top level). Without --variant the base is read, without any [variant] table. Every declared variant is validated on every load, and crucible check compiles every variant's graph, so a broken variant fails whichever one runs. A resume must name the variant the run started with. A composite manifest declares no variants.

1.1 The gate self-test (crucible check, ADR-0012)

[judge.selftest] declares two controls the gate must tell apart before it's trusted. crucible check (§9) runs it pre-loop, never inside a loop iteration:

  1. snapshot the pristine workspace,
  2. restore to pristine, stage good_cmd, measure runs times through the domain's own Judge, restore to pristine again,
  3. same for bad_cmd,
  4. pass iff both controls' readings are all valid and good's mean score is strictly better than bad's per [judge].direction.

The workspace is restored to pristine on every exit path (pass, fail, or error). A manifest with no [judge.selftest] isn't an error, crucible check warns instead, since the gate hasn't been proven to discriminate.

1.2 Wide-round search ([search], ADR-0010)

[search].wide > 0 (or --wide N on the CLI, which overrides the manifest) fans out N independent PROPOSE turns in per-candidate git worktrees under the state dir before the deep loop starts, one turn per approaches entry biased into its prompt. Each candidate's diff is applied (cherry-picked) into the shared main workspace and measured serially there (measurement never runs concurrently, only proposal does). The scored set is ranked by [search].policy (v1: "top-k"); the policy_k (or --wide-keep K) winner(s) seed the deep loop, which then runs as normal. Session rows from the wide round carry an additive phase: "wide" field (§7) so a consumer can tell a wide-round row from a deep-loop row without a wire-shape change.

1.3 Scope-authored workflows (workflow.star)

A scoped pack may include workflow.star beside crucible.toml. It is authoring syntax, not a runtime interpreter: scope compiles it to the existing [[workflow.task]] manifest IR before validation and again before freeze. The generated TOML is the runtime authority. This keeps plan authorship readable without moving the frozen judge or execution semantics into Starlark.

Topology is authorable; authority is not. A workflow declares type = "autoresearch" or type = "custom", and an engine or outer orchestrator admits it only when it advertises the matching workflow capability. Each engine task also requires its own capability. Crucible's loop advertises workflow.autoresearch plus propose/apply/measure/grade/decide, while its generic plan runner does not. It adds agent.session.persist only when the selected backend and harness can honor an opaque continuation. Serializing an engine task never grants access to the World or frozen Judge.

An autoresearch workflow is checked by semantics rather than reserved task names. Its selected result must be a decide task sourced from a frozen measure or authored grade, with apply and propose ancestors. Tasks may be inserted anywhere, operations may be renamed, and multiple candidate or measurement branches may exist. A custom workflow has no autoresearch-shape requirement; universal DAG, source-typing, and operation-capability rules still apply.

The basic autoresearch flow is explicit and editable:

candidate = propose(name = "invent", session = "solver")

critics = [
    agent(
        name = "correctness",
        prompt = prompt_file("prompts/correctness.md"),
        model = "claude-opus-4-6",
        effort = "high",
        isolated = True,
        depends_on = [candidate],
    ),
    agent(
        name = "novelty",
        prompt = prompt_file("prompts/novelty.md"),
        required = False,
        isolated = True,
        depends_on = [candidate],
    ),
]

synthesize = agent(
    name = "synthesize",
    prompt = prompt_file("prompts/synthesize.md"),
    session = "solver",
    depends_on = critics,
    join = "passed",
)
smoke = command(name = "smoke", run = "./smoke.sh", depends_on = [synthesize])
live = apply(name = "deploy-preview", depends_on = [smoke])
score = measure(name = "benchmark", depends_on = [live])
decision = decide(name = "keep-if-better", measurement = score)

workflow(
    type = "autoresearch",
    tasks = [candidate] + critics + [synthesize, smoke, live, score, decision],
    result = decision,
)

default_autoresearch(extra_tasks) is the compatibility convenience. It expands to the same ordinary propose/apply/measure/decide tasks, attaching unconnected extras after proposal and before apply. A missing workflow.star retains the historical default loop behavior. The old positional workflow(tasks) form remains accepted as a splice adapter while packs migrate.

Measurement can remain the historical opaque measure() call, or be authored as a visible DAG:

live = apply(name = "deploy", depends_on = [candidate])
correctness = evaluate(
    name = "correctness",
    run = "./correctness.sh",
    depends_on = [live],
    isolated = True,
)
latency = evaluate(
    name = "latency",
    run = "./latency.sh",
    depends_on = [correctness],
    threshold = 12.5,
    direction = "lower",
    isolated = True,
)
racecheck = evaluate(
    name = "racecheck",
    run = "./racecheck.sh",
    depends_on = [correctness],
    required = False,
    isolated = True,
)
measurement = grade(
    name = "grade",
    evidence = [correctness, latency, racecheck],
    score = latency,
)
decision = decide(name = "choose", measurement = measurement)

Dependencies define measurement rungs; ready isolated siblings run concurrently. evaluate() expects a JSON object on its last stdout line. pass = false vetoes a result, malformed pass fails closed, and paired threshold/direction fields grade numeric score. Without a threshold, omitted pass means success. grade() selects a passing score evaluator and folds passing evidence by default; set join = "all" for a strict join. The legacy measure() path and default workflow are unchanged.

session = "solver" binds agent-producing tasks to a durable logical conversation. The checkout may roll back after a discarded candidate while the solver session continues forward and retains what it learned. Tasks sharing a session must be dependency-ordered and cannot be isolated; parallel critics should stay fresh or use distinct sessions. Admission requires agent.session.persist. A missing session preserves the historical fresh-turn behavior.

Sessions can also be declared first-class with session(...) and bound by value:

solver = session(name = "solver", model = "claude-opus-4-6", effort = "high")
candidate = propose(name = "invent", session = solver)
refine = agent(name = "refine", prompt = prompt_file("prompts/refine.md"), session = solver,
               depends_on = [candidate])

Declarations are compile-time only; the generated manifest carries the same per-task session, harness, model, and effort fields as before. The rules:

  • A declaration's harness / model / effort are defaults that materialize onto every agent task bound to it. A bound task may repeat a value but not contradict it: one session is one serial conversation under one agent config.
  • A session carrying defaults cannot bind to propose(), whose agent config is owned by the manifest's [agent]. A default-free declaration binds to it exactly as a string does.
  • Duplicate declarations of one name, and declarations never bound to a task, are compile errors.
  • While a file declares no sessions, bare strings keep the historical pass-through behavior. Once any session() exists, every string session = "x" must name a declared session (declared before use), so a typo can no longer silently open a second fresh conversation.

Tasks may also declare their output contract: emits = ["score", "pass"] on agent(), command(), or evaluate() names fields the task's JSON output promises to include. Compilation rejects a top_k dependency, grade score source or tiebreak, or thresholded evaluate whose declared emits omits score; at runtime a passing attempt missing a declared field becomes a measured failure at the producing task instead of a mystery downstream. An absent emits declares nothing and changes nothing. A dict, emits = {"score": "number", "tier": ["high", "low"]}, also promises each field's type; a wrongly typed field fails the producing task the same way, and compilation checks the types against over, score readers, and output-decided routes. A type may also be schema_file("path"), a JSON Schema the value must satisfy, and emits_files takes a dict from path to schema_file(...) or None. See Work graphs.

Compile errors carry file:line:col and a did-you-mean suggestion for unknown functions, kwargs, variables, and session names. A behavioral change from earlier releases: a task constructed in workflow.star but omitted from workflow(tasks = ...) is now a compile error naming the construction site, because a silently dropped task is a silently weakened measurement. Delete the assignment or include the task.

Crucible's private ledger contains only the logical name, an opaque harness cursor, and a completed-turn count. It never copies that cursor or Claude's native transcript into session.jsonl; the existing live harness event policy, including streamed thinking events, is unchanged. Claude Code implements native start/resume for both the local and Vertex/OpenShell paths. Because OpenShell sandboxes are per-turn-fresh, Crucible saves Claude's native transcript as mode-0600 private engine state and restores it at the exact pinned config path in the next sandbox; that file is never included in the published run record. The other harnesses have no equivalent continuation store, so they fail closed for a persistent binding; none silently degrades the binding to a fresh prompt.

The first turn receives the complete method and goal prompt. A resumed proposer receives only the new authoritative delta: current regime, current-best status, and new steering. Its retained session already holds the stable instructions and hypotheses, while the current checkout and RESULTS.md remain authoritative after any world rollback.

A custom orchestrator can admit a graph without a research lifecycle. For example, a creative studio fans out three treatments, curates them, and publishes a contact sheet:

treatments = [
    agent(name = "surreal", prompt = prompt_file("prompts/surreal.md"), isolated = True),
    agent(name = "minimal", prompt = prompt_file("prompts/minimal.md"), isolated = True),
    agent(name = "documentary", prompt = prompt_file("prompts/documentary.md"), isolated = True),
]
curate = agent(
    name = "curate",
    prompt = prompt_file("prompts/curate.md"),
    depends_on = treatments,
    join = "passed",
)
publish = command(name = "contact-sheet", run = "./render.sh", depends_on = [curate])
workflow(type = "custom", tasks = treatments + [curate, publish], result = publish)

That graph requires workflow.custom. It does not satisfy the autoresearch shape, although it uses agents and commands.

This is Starlark over a constructor-only global surface: assignments, strings, numbers, booleans, lists, def, if/else, for, comprehensions, and calls to the functions below, at the top level or inside a def. load() resolves against the pack directory only. Task, session, and workflow values are opaque and immutable, so they can be referenced directly in depends_on, measurement, and result without repeating names, and a library cannot mutate one after handing it back.

The declared lane decides which constructors exist. Which lane each belongs to, and what arguments each takes, is the DSL reference, generated from the compiler's own tables. A playbook does not have the scored lane's constructors in scope at all, so naming one is an unknown-name error where it was written, and a did-you-mean never offers one.

  • propose(...), apply(...), measure(...), grade(...), and decide(...) create capability-owned engine tasks. decide(measurement = score) selects its measurement. Scored lanes only.
  • agent(...) creates an agent task. isolated = True gives it a disposable worktree, ideal for concurrent read-only critics; leave it false for a synthesizer whose edits must survive. session = "name" opts into an engine-managed durable conversation.
  • session(name = ..., harness = ?, model = ?, effort = ?) declares a durable conversation with optional agent defaults, bindable as the session = value on agent() and propose().
  • command(...) creates a deterministic shell task in the candidate workspace.
  • evaluate(...) creates a typed measurement command with optional threshold grading.
  • top_k(...) creates a reducer for wider authored graphs. Scored lanes only.
  • prompt_file(path) reads a regular UTF-8 file below the pack directory and embeds its contents in the generated manifest. Absolute paths, .., symlinks, non-files, and oversized inputs are rejected.
  • agent(..., repair = N) and skill(..., repair = N) resume the task's conversation up to N times (at most 3) to fix a passing turn whose output breaks its declared fields or files. See Work graphs.
  • schema_file(path) reads a JSON Schema (draft 2020-12) below the pack directory, under the same path policy, and embeds its content as a field type in emits or a file's schema in emits_files. A file that is not a valid 2020-12 schema, or references a remote $ref, is rejected.
  • load(path, name, ...) pulls symbols from another .star file under the pack, resolved by the same policy as prompt_file and refused for absolute paths, .., symlinks, non-files, and cycles. Loaded modules run before the root against the same globals and the same compile state: their prompt_file() and schema_file() calls resolve against the pack root and charge the same byte budget, their session() declarations precede every root reference, and a task they construct at module level must still appear in workflow(tasks = ...). Re-export is off, so a symbol a library loads is not visible through it.
  • workflow(type = ..., tasks = ..., result = ...) is the explicit final expression. A list of tasks is accepted anywhere a list of task names is, in tasks and in depends_on alike, so a list never needs wrapping to be passed.
  • default_autoresearch(extra_tasks) expands the historical loop into fully visible nodes. Scored lanes only.

Five kwargs govern how a task runs and what its failure costs. They are independent, and this is the whole of it:

kwargvalueswhat it decides
requiredTrue (default), Falsewhether this task's failure invalidates the run. An advisory task's failure blocks only its dependents.
join"all" (default), "passed"what this task needs of its dependencies. "all" needs every one to have passed; "passed" runs on whatever survived.
isolatedFalse (default), Truewhether the task gets a disposable worktree. Today this is also what buys concurrency, because non-isolated peers would race on the shared result file.
needs"any" (default), a capability namea capability the run must have before this task is dispatched.
stage"iteration" (default), "epilogue"whether the task is in the main graph, or runs once after it settles.

required = False and join = "all" do not compose: a required task may not depend, through a path of "all"-join edges, on an advisory one. That graph says a failure is both tolerable and disqualifying, and no run of it yields an honest verdict. Validation rejects it before dispatch, naming both tasks. join = "passed" is the exemption, because it declares up front that the task runs on whatever survived.

Agent tasks receive upstream results in their prompt and write one JSON object to PLAN_TASK_RESULT.json. Required failures discard the candidate; advisory tasks use required = False. join = "passed" waits for all dependencies, then receives their non-empty set of successful results. No passing input blocks the task.

The two settings must agree: a required task may not depend on an advisory task with join = "all", and validation rejects the graph naming both tasks. An advisory task is allowed to fail, and a join = "all" dependent blocks on that failure, so the required = False would buy nothing. Consume advisory work through a join = "passed" task, which is exempt along with everything reachable only through it. The legacy positional workflow(tasks) splice has no lever for this: its tasks feed the loop's required apply, so a spliced sink cannot be advisory.

For local review, crucible plan compile-workflow --file workflow.star prints stable canonical JSON. Add --manifest crucible.toml to also replace the generated [workflow] block. Compilation applies source-size, loaded-module, task-count, constructed-task, evaluation-tick, heap, call-depth, and prompt- and schema-size ceilings. The compiler exposes no filesystem API except prompt_file, schema_file, and load, and no process, environment, network, clock, or randomness API. Scope validation renders the admitted graph to WORKFLOW.png for the scope PR, grouping evaluate and grade as Measurement.


1.9 [outputs] and [capabilities]: declared writes and disclosed reach

[outputs] bounds the run's mediated writes (RFC-0001:C-OUTPUTS). The kind vocabulary is closed and engine-defined; a kind outside it is a manifest error.

# A declaration carries a per-run count, and a target for a kind that addresses one.
[outputs.image-push]
count = 20
target = { fixed = "quay.io/aipcc" }

# An OPEN target names a scope narrower than the kind's whole address space, and may bind that
# scope to a workflow parameter. The target must then equal that parameter's run value, which is
# how a run fanned out per tracker item confines its writes to the item that parameterized it.
[outputs.tracker-comment]
count = 3
target = { open = { scope = "PROJ-*", param = "issue_key" } }

A scope that admits any target ("*", "**", "") is rejected at validation. gpu-capture addresses nothing, so it takes a count and no target; every other kind requires one.

A kind the pack does not declare resolves to the engine default table. Every default carries a count and none carries an open target, so a pack shipping no [outputs] gets the conservative posture. A default whose target does not resolve refuses every write of that kind.

kinddefault countdefault target
draft-pr2[publish].pr_repo, else $AUTORESEARCH_PR_REPO
tracker-comment2the run's parameterizing item ($CRUCIBLE_ITEM)
chat-message8the engine's operator channel
image-push100$FORGE_REGISTRY
deploy100$FORGE_DEPLOY_NAMESPACE/$FORGE_DEPLOY_NAME
workflow-dispatch20the single [build.*.github].repo, when unambiguous
gpu-capture100addresses nothing

Bounds are enforced in the broker, at one chokepoint every mutating tool routes through. They are projected there from the frozen manifest as BROKER_OUTPUTS (with BROKER_OUTPUT_PARAMS and BROKER_SESSION_LOG), handed to the broker child only, so nothing inside the sandbox can alter one. The engine writes two kinds itself, not through a broker tool: the draft PRs that publish-on-keep opens, and the workflow_dispatch that a github-actions build fires. The engine mediates these against the same resolved value. A write over a count or outside a scope fails that write naming the bound, writes an output_refused row on the session log, and does not terminate the run: publishing skips the remaining candidates and the run completes.

[[capabilities.secret]] states what a credential authorizes; a name alone does not.

[[capabilities.secret]]
name    = "JIRA_API_TOKEN"
context = "broker"            # or "agent": whether the value enters the sandbox
system  = "jira"
scope   = "read + comment on PROJ"

The rest of the disclosure is read from what the manifest already declares: the resolved egress allowlist (each entry labelled builtin or manifest), every [agent].env name, every [[agent.relay]] destination, a substituted [agent.broker].bin, and whether the pack runs commands outside the sandbox (workflow command/evaluate tasks, world and judge hooks). At run start, an agent-visible env value or a relay file the disclosure does not cover is refused, naming the grant and what is missing.

crucible check prints the resolved bounds and the disclosure; crucible plan exposure --manifest crucible.toml emits the same as JSON ({version, outputs, capabilities}) for the controller to extract. Both compute it without executing pack content.


2. Path resolution (portable, never CARGO_MANIFEST_DIR)

ThingResolves to
config: method_prompt, goal_file, toolbox_dirmanifest-relative
agent workspace (the measured checkout)manifest_dir / [workspace].dir
runtime state (session.jsonl, admissions.jsonl, control.json)--state-dir, default manifest_dir/state
STEER.md--steer, default manifest_dir/STEER.md
ESCALATION.json (agent's harness-blocker marker, ADR-0001)<workspace>/ESCALATION.json: written by escalate, consumed by the engine post-turn

The binary's own install location is never used to resolve anything. A target repo is self-describing: drop a crucible.toml at its root and run crucible inside it.


3. The command protocol

Every command (measure_cmd, apply_cmd, snapshot_cmd, restore_cmd, setup_cmd) is a string executed via sh -c "<cmd>", with:

  • cwd = the agent workspace (manifest_dir/[workspace].dir).
  • PATH inherited, so a command may be a bare installed tool (bench) or a workspace-relative script (./measure.sh).
  • env, edition 2 = PATH, HOME, LANG, TMPDIR, the run's identity (CRUCIBLE_RUN_NAME, CRUCIBLE_RUN_DISPLAY_NAME, CRUCIBLE_UI_BASE_URL) when the engine has it, the injected variables below, and what the command's table declares in env: [judge].env for measure_cmd, [world].env for apply_cmd, snapshot_cmd, and restore_cmd. A declared NAME the engine's environment does not set refuses the run; a declared NAME? is passed when set and left out when not.
  • env, edition 1 = the engine's env plus the injected variables below. crucible check and run start warn about it, and the capability disclosure states it.
  • setup_cmd gets the engine's env in every edition.

measure_cmd (the Judge): REQUIRED

  • Injected env: CRUCIBLE_BASELINE_SCORE, CRUCIBLE_BASELINE_TOTAL, CRUCIBLE_BEST_SCORE (absent on the baseline measurement; present thereafter). Values are the engine's current numbers as decimal strings.
  • stdout: the engine reads the last line that starts with { and parses it as:
    { "valid": true, "score": 12.5, "solved": false, "note": "p99=12.5ms", "detail": { } }
    
    • valid (bool, REQUIRED): false ⇒ unscoreable candidate, always discarded.
    • score (number|null): the fitness. null/absent ⇒ treated as invalid.
    • tiebreak (number, optional): secondary fitness for functional gates whose score is effectively boolean; on an exact score tie, a strictly better tiebreak still keeps (§4).
    • solved (bool, optional, default false): the win condition was met (terminates the loop).
    • note (string, optional): one-line human summary.
    • detail (object, optional): free-form; surfaced in the row + session log. The domain stashes anything extra here (e.g. EPP cache_hit_rate; the test gate's total).
  • exit code: nonzero ⇒ the reading is forced valid:false regardless of stdout.

apply_cmd (make the candidate live): optional

  • Run after the agent turn, before measure, when present. Nonzero exit ⇒ the iteration is treated as an invalid candidate (discard). No stdout contract.
  • Omit for code/agent-deploys domains (EPP: the agent deploys via skills during its turn; the counter: the edit is the candidate). Present for engine-driven build+push+set-image.

3.1 Build mode: how an edit becomes the thing measured

Between "the agent edited a file" and "measure_cmd read a score" sits a step whose shape you must choose when you design a pack. It differs per component, it is the dominant term in per-iteration wall-clock, and picking it wrong fails silently. Full rationale in ADR-0016.

ModeWhen it appliesWhat apply_cmd doesCost
no artifactthe gate compiles + runs the workspace in placenothing (omit apply_cmd)seconds
no rebuildthe agent changes config of a live rigpush the config, wait for rollouta rollout
derive-layerthe changed sources are interpreted (Python)append them onto a pinned base as a real OCI layer~8s
imagethe changed sources are compiled (Go, Rust, C++)a real container build; the compile happens hereminutes

Rules that are easy to get wrong, and that crucible check should catch before you spend a turn:

  • derive-layer requires that the base image and the push target share a registry. The layer mounts server-side only when they match; across registries the same operation streams every base blob (8 seconds becomes 20+ minutes) and nothing warns you.
  • derive-layer cannot carry compiled sources. Appending a .go file to an image changes nothing that runs. The loop measures the base image forever and reports every candidate as a no-op, the failure mode ADR-0007 exists to catch.
  • A compile failure must be distinguishable from a bad score. In image mode the build is the loop's fastest feedback signal: a compile error is returned to the agent as a free retry with no candidate spent. An apply_cmd that collapses "did not compile" into "scored badly" throws that away.
  • A Containerfile on the measured path belongs to the judge. If the build recipe lives in the agent's workspace, it must be a frozen = true inject (§1), re-copied before every scored measure. Otherwise the agent can edit the recipe that builds the artifact it is scored on (vendor a prebuilt binary, neuter the compile), which is the ADR-0001 trust line, broken.

snapshot_cmd / restore_cmd (domain reversibility): optional, must come as a pair

  • snapshot_cmd: stdout last line = one opaque token (any non-empty string; base64 it if it contains newlines). Nonzero exit ⇒ snapshot failed (engine aborts the keep).
  • restore_cmd: receives the token in env CRUCIBLE_TOKEN. Rolls external state back to that token. Nonzero exit ⇒ restore failed (engine surfaces it). (Env, not argv, so a multi-line base64 payload round-trips without shell-quoting.)
  • The engine treats the token as opaque and never parses it. (§5 explains how the engine frames its own git ref alongside this token.)

setup_cmd (prepare the workspace): optional

  • The one cwd exception: runs with cwd = manifest dir (the workspace does not exist yet, it's what setup creates). Every other command runs with cwd = workspace.
  • Default when omitted: engine does git clone [repo] <workspace> && git checkout [ref]; with no [repo] (a playbook), it creates <workspace> empty. Either way the injects land next and the engine git inits the workspace and commits the result as the baseline when setup left no repo behind, so a setup_cmd only has to produce the tree, never the commit. A setup_cmd that commits before returning freezes a baseline without the injects.

4. The decide rule (universal, no per-domain code)

Given a Reading { valid, score, solved, note, detail }, the current best_score, and the manifest direction:

keep   = valid && score.is_some() && (better(score, best_score, direction)
                                      || (score == best_score && tiebreak_better)
                                      || solved)
solved = reading.solved
better(s, b, lower)  = s < b
better(s, b, higher) = s > b

tiebreak_better applies only when the reading carries a tiebreak: it is better(tiebreak, best_tiebreak, tiebreak_direction), where tiebreak_direction is [judge].tiebreak_direction (optional, defaults to direction) and a best with no recorded tiebreak counts as the worst value. A reading without a tiebreak ties exactly as before: discard.

  • solved implies keep. The engine keeps a candidate that the measure command declares solved, and ends the loop, even if its score is not strictly better than the best. This rule is necessary for a domain whose win has an equal score. For example, the EPP test gate wins with a passing suite (0 failures, equal to the 0 of the baseline) and a new regression test. Without || solved, the engine would discard that win, and the loop would not finish. solved does not make an invalid reading valid.
  • The first valid reading sets the baseline (best_score, and baseline_total = detail.total if present) and is always kept.
  • The loop terminates when a kept iteration is solved, or budget/iterations exhausted, or stop/escalate.
  • No domain Rust decides anything. A win condition more complex than "better score" (e.g. EPP's "green AND a new regression test") is computed inside the measure command, which reads CRUCIBLE_BASELINE_TOTAL and emits solved.

5. World = reversibility (engine never names git)

World::Snapshot = String, opaque to the engine (run_loop only round-trips it back to restore).

  • GitWorld (default): snapshot() = stage+commit the workspace, return the commit SHA; restore(sha) = git reset --hard <sha> + git clean (excluding .claude/, RESULTS.md). The kept-commit chain is the memory. Works for any git repo, zero domain code.
  • CommandWorld (any [world] command given): always owns git memory as above, and layers the domain commands. The snapshot token it stores is the composite "<git-sha>\t<domain-token>":
    • keep: commit (git half) → run snapshot_cmd, capture its token (domain half) → join.
    • discard: split → git reset --hard <sha> + clean → run restore_cmd with CRUCIBLE_TOKEN=<domain-token>.
    • The engine exposes last_commit_sha() (the git half) for kept_shas/publish; the domain half is never inspected.

The engine's loop body calls only world.snapshot() / world.restore(&snap). It contains no git/vcs:: calls and no kubectl.


6. Agent transport (AgentSource)

The engine renders a prompt (method_prompt with {{GOAL}}/{{STATUS}}/{{STEER}} filled), hands it + the workspace to an agent that edits the workspace, and never hands it the Judge (ADR-0001). Backends, selected by [agent].backend:

  • local: direct claude --output-format stream-json with [agent].env (real Claude/Vertex turn).
  • openshell: sandboxed pod turn driven by the in-Rust OpenShell driver (backend = "openshell", sandbox_image). [agent.resources] sets the sandbox's GPUs, CPU and memory, and node_selector picks the nodes by label. It takes effect only where sandboxes are Kubernetes pods (deploy profile [cluster] sandbox_driver = "kubernetes"); the controller refuses a launch anywhere else, and the engine refuses the table on any other backend. The deployment's [cluster.gpu_sandbox] (node selector, tolerations, runtime class) is added to every GPU sandbox, and a selector key it sets keeps its value over the pack's.
  • command: run [agent].agent_cmd via sh -c in the workspace as the proposal. A deterministic proposer with no cost (no LLM). It is a real transport. With it, the minimal example is a fast, deterministic e2e test (for example, agent_cmd = "./bump.nu" increments a counter). Use it to test the loop and protocol of the engine without token cost.

One vs two execution environments (the only backend fact a domain must know)

The engine and its measure/apply/snapshot/restore/setup commands always run where crucible runs. Only the agent turn is sandboxed under openshell. So:

  • local / command: one environment. Agent and engine share the host PATH + filesystem; the agent edits the workspace in place. Provide one toolbox on PATH. Nothing else.
  • openshell: two environments. (1) the engine/loop-image PATH runs the contract commands; (2) a separate sandbox image runs the agent's skills. The OpenShell driver uploads the workspace into the sandbox and syncs edits back, so anything the agent needs to reach the outside (kubeconfig, tokens) must be relayed via [[agent.relay]] or provided in [agent].env (the sandbox is network/fs-isolated). A domain therefore ships two tool surfaces under openshell: contract commands on the loop PATH, agent skills baked into sandbox_image.

The manifest [agent] block (backend, sandbox_image, env, relay, openshell) selects and configures the backend. A change between local and openshell is a configuration change. It needs no code change. The manifest cannot build the sandbox image or supply credentials. The domain must do this for a sandbox. Reversibility commands (snapshot/restore) run in the engine environment, so they reach the live system directly with every backend. Only the agent runs in the sandbox.

6.1 Sandbox egress ([agent.openshell])

The sandbox is deny-by-default. Two lists open it back up, and a flag decides whether they extend the engine's built-ins or replace them:

[agent.openshell]
endpoints = ["api.example.com:443:full"]  # host:port:access[:proto[:enforcement]]
binaries  = ["/usr/local/bin/claude"]     # only these may open a socket
inherit_defaults = true                   # default

With inherit_defaults = true (the default) the lists are appended, de-duplicated, to the built-ins: the public forges, PyPI, Vertex, Anthropic, and the agent CLIs. Appending can never remove a built-in.

Set inherit_defaults = false and the resolved allowlist is exactly what the manifest names, binaries included. This is the only way to subtract a default, and it is required for two cases:

  • Air-gapped or private-registry runs, where the public internet is not reachable (or not permitted) and the agent must talk to an in-cluster model endpoint and registry instead.
  • Contamination control. An agent scored on an upstream issue can, with the default allowlist, read the upstream fix for that issue on github.com. A measurement that must not be polluted by the open web has to drop that endpoint, and dropping it means opting out.

The broker endpoint is auto-appended by the engine when [agent.broker].enabled is true (ADR-0015). The engine first resolves the broker URL (an explicit [agent.broker].url override, or derived from the active compute driver's hostname), then derives the egress host:port:full entry from that URL's authority. When no explicit port is present, the scheme default applies (http = 80, https = 443). Both values come from the same resolved URL, so the allowlist entry and the address that the sandbox contacts are always the same. The engine appends the broker endpoint for every value of inherit_defaults, because the domain enabled the broker. A domain cannot remove it as it removes a built-in. A configuration without a broker and with both lists empty is a valid total air-gap: no host resolves, and no binary can open a socket.

6.2 MCP token file (MCP_TOKENS_FILE)

An [mcp] server authenticates sandboxes against the file MCP_TOKENS_FILE names. The engine replaces it by rename on every grant and revoke; the server re-reads it on every request. One line per sandbox, fields separated by whitespace:

<token> <sandbox> <workdir>

<token> is the bearer the sandbox's requests carry, <sandbox> the gateway's name for the sandbox, and <workdir> the absolute path its agent runs in (/sandbox/workspace for a loop turn, /sandbox/task-<sha256> for an isolated plan task). A two-field <token> <sandbox> line parses with the workdir unknown. A request whose bearer is in no line gets 401.


7. Session wire format (compatibility)

The NDJSON session log (state/session.jsonl) keeps its existing event kinds (start/phase/row/budget/summary/finished) and field names unchanged. In particular the objective label is still written under the JSON key gate (now carrying a free-text label like "score"/"bench", not the deleted enum). This keeps --resume, the remote viewer, and already-published S3 runs loading. Do not rename the wire key.

A row event's wire record (RowWire) carries an additive, optional phase field ("wide" for a wide-round candidate row, absent for a deep-loop row). It's skip_serializing_if = "Option::is_none", so a deep-only run's wire bytes are unchanged from before wide rounds existed.

A phase event's phase is one of starting/preflight/baseline/wide/iteration/ paused/parked/distressed/escalated/epilogue/finished, the same token the control bridge's status reply carries, emitted once each time the loop's (phase, iter) pair changes. The three tokens logs carried before contract 1.3.0 are a subset, so old logs still decode.

A task_result event whose status is blocked carries an additive blocked object: { reason, task? } with reason one of required_task_failed/budget_ceiling/ wall_clock_ceiling/dependency_did_not_pass/staging_refused and task naming the required task whose failure short-circuited the plan (present only for required_task_failed). note stays the rendered form of the same reason. report.json's per-task entries carry the same object.

A task_result event whose status is transport carries an additive transport token (contract 1.4.0): what the last attempt died on, one of gateway/sandbox/agent/provider/ workspace/command/other. note stays the retry summary with the last attempt's detail. report.json's per-task entries carry the same token.

A task_result event's status may be not_taken (contract 1.6.0): the task's when was not satisfied, or it joins all on a task that settled that way. It was never dispatched, cost nothing, and is not a failure. A plan_admitted task carries an additive when string, route.question in a|b, empty when the task is unconditional.

A plan_admitted task carries optional (contract 1.20.0), the dependencies declared with optional(...), omitted when there are none. A task joining all dispatches past such a dependency that settled not_taken, and its inputs carry no entry for it. An engine older than 1.20.0 ignores optional in a compiled graph and settles the task not_taken instead.

A task_result event carries an additive links array (contract 1.12.0), omitted when empty: the external results the task reported, one object per url with url, provider (github/gitlab/jira/other), kind (pull_request/merge_request/branch/commit/ compare/issue/page) and a short label. The engine fills it from the output fields the task declared link or links, so every url in it is an http(s) url it validated. A reader renders these rather than reading urls out of output.

A plan_admitted task carries definition (contract 1.17.0), the digest of the compiled task with its parameters bound and of its dependencies' definitions. A task_result event carries reused_from when a rerun reused the row instead of dispatching it, naming the run that settled it; omitted otherwise. A playbook run delivers its git memory as the run-commits artifact: a gzipped tar of one patch per task commit, tasks.json naming each commit's task in order, and playbook-run.json, the record of where the run started.

A plan_admitted task carries keyed (contract 1.16.0), each producer.field a mapped task narrows to its own item's entry, empty when it declares none. A mapped node's folded output and its task_result fan-out summary count the instances that settled not taken under not_taken; a reader that predates it reads the count as zero. A route may declare over, and its instances settle as route[K], each with the decision for item K as its output. A score question's answer carries score, its expected level position in [0, 1], and asked_as, score or choice, the form the decision API was asked in.

A route question may be a dynamic_choice (contract 1.22.0), whose options_from = {task, field} names a dependency field the options are read from at each decision: the list itself, or for a route with over the item's entry in an object keyed by item. Each entry is a label or {value, description}, at most 64, a repeated label keeps its first entry, and fewer than two fails that decision before it is asked. A model-decided route asks it as a choice; a human-decided route opens its request with the resolved choice in questions. Its answer carries options, the {label, description?} list it was asked over, whatever the label; no other answer carries it. A when on such a question may carry any_option = true, satisfied by any label but uncertain, and a plan_admitted task's when string spells it *, as in match.package in *|uncertain.

A plan_admitted task carries emits_files (contract 1.14.0): each declared path, as a string, or {path, schema} when the declaration gave a schema. A field typed by a schema carries {"schema": "<JSON Schema text>"} as its type. A task_result event carries repairs (contract 1.14.0), one {label, round, of, cost_usd, notes} per repair turn an agent task took, omitted when there were none; their cost is already part of cost_usd.

A task_result event carries an additive agent object (contract 1.13.0) on an agent task and on the loop's candidate turn, omitted on every task that runs no agent: harness, model and effort, as the executor resolved them for the attempts it made. The task's own knobs have already been applied over the CLI flags and the manifest's [agent] defaults, so a task that pinned its own model reports that model rather than the run's, and model is what the harness hands the turn, which for a harness carrying a model slot of its own is not always [agent].model. effort is empty when nothing pinned one. Every field defaults, so a reader older than the writer keeps the fields it knows.

A plan_admitted task carries an additive timeout string (contract 1.8.0), the task's own per-attempt limit in the --max-time syntax (90s, 20m, 2h), empty when only the run's wall-clock ceiling bounds it. A task killed at its deadline settles fail with a note naming the limit that ended it.

A plan_admitted task's revise (contract 1.11.0) is a list of task names when the task reviews a chain, and stays a single name, or empty, otherwise. A reader should accept both.

An orchestrator tells the engine where models are reached through one JSON document in CRUCIBLE_INFERENCE (contract 1.7.0), typed as crucible_contract::inference::ResolvedInference: {"version":1,"bindings":[{"role","protocol","url"?,"model","key_env"?,"price"?}]}. role is agent or decision; protocol is messages, chat_completions, responses, system_one, or decisions (contract 1.15.0, the OpenAI Decisions API, at https://api.openai.com/v1/decisions when url is absent). A decision binding speaks system_one or decisions. key_env names the variable holding the credential and the document never holds the value. An unknown field or version fails the run before any task. A decision binding is what a model-decided route asks. An agent binding is the whole answer for agent turns: its model is the run's model, its protocol keeps the selected harness when that harness speaks it and otherwise selects the one that does, its key replaces [agent.codex]'s own, and the ambient ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, OPENAI_BASE_URL, and CRUCIBLE_INFERENCE_WIRE_API are not consulted. Each replacement is logged. With no agent binding those ambient variables and the manifest decide, which is how a run from an operator's shell works.

An agent binding may carry price (contract 1.19.0): {"input","output","cache_read","cache_write"}, each a finite, non-negative USD rate per million tokens. With a price, every agent turn of the run costs its largest token sample times those rates, for any model, and whatever cost the harness or OTEL reports is ignored. Without one, a vendor-billed model (an Anthropic or OpenAI name) costs what the harness reports, or the engine's table estimate when it reports nothing, and any other model costs nothing whatever the harness reports. A price on a decision binding fails the run. An engine older than 1.19.0 refuses a document that carries a price as an unknown field.

An orchestrator that starts a run of a launch series supplies the series' earlier terminal runs as one JSON document in CRUCIBLE_HISTORY (contract 1.9.0), typed as crucible_contract::history::SuppliedHistory: {"version":1,"records":[record]}, the most recent runs by end time, at most 30. A record is exactly {"run","started_at","ended_at","outcome","verdict","revision","link","entry":{"task","status", "output"}}: the run's identifier, its RFC 3339 start and end, its shutdown outcome token or null where it wrote none, its verdict (valid/invalid) or null, the pack revision it ran, a controller-owned link to it, and the history entry it recorded (below). Every field is present, nullable ones as null. An unknown field or token, a missing field, a version other than 1, more than 30 records, a repeated or empty run, an unparseable time, or an end before its start fails the run before any task. With no CRUCIBLE_HISTORY the run belongs to no series.

The engine orders the records by end time, oldest first, and gives a task declaring history = N the reserved input history: {"records": [...], "dropped": k}, the last N records exactly as supplied, less whole records from the oldest end until the compact JSON encoding of that object fits CRUCIBLE_HISTORY_MAX_BYTES (default 65536), with k the number removed. A run outside a series gives such a task {"records": [], "dropped": 0}. A command or evaluate task reads it from the inputs file CRUCIBLE_INPUTS_FILE names; an agent task reads it in its prompt, inside the external-input markers, and never in its upstream-results JSON. crucible check prints the bound a playbook's run will apply, and refuses a malformed one.

A plan_admitted event carries an additive history_record, the task a playbook records for its series or empty when it names none, and each task an additive history_depth, zero when it reads no history (contract 1.9.0).

Additive event kinds beyond the compat set include:

  • identity: the run's RunIdentity (below), emitted once at setup and again on --resume (the freshly recomputed identity). A mismatch against the original run's identity is a hard-warning note event, never an abort.
  • history_entry: { entry: { task, status, output } }, emitted once by a playbook run, just before shutdown (contract 1.9.0). task is the playbook's history_record, empty when it names none. status is that task's task_result status, and output its JSON output cut down to the fields it declares in emits, null unless it passed. A revise target or reviewer records its final result, not a round's. Both are null when the task never settled: the run stopped early, or a ceiling left it undispatched. An orchestrator copies this object into the entry of the record later runs of the series receive.
  • shutdown: { outcome, reason }, emitted exactly once, as the last line of every run (after finished/summary). outcome is one of finished/solved/budget/stopped/ escalated/stalled/error. Session-log consumers key a run's terminal state off this line; a dead stream with no shutdown line means the pod likely died mid-run, not a clean exit. --resume depends on this invariant: a resumed run classifies the log tail (see recovery below) and a trailing shutdown is the "exited on purpose" signal. In a resumed (appended) log, only the trailing shutdown counts; one followed by more events belongs to an earlier process.
  • agent_session: { session, action, turn }, emitted before a persistent agent turn so a viewer can draw continuation lanes and distinguish started from resumed. It deliberately contains neither the provider cursor nor native transcript content.
  • approval_wait: { handle, trace_id, mode }, emitted when the loop reads the agent's pending-provisioning marker. mode is block (the loop parks idle) or continue (it keeps iterating in the frozen regime). Bracket invariant: every approval_wait is closed by an approval_resolved except on stop-while-parked and process death, so a dangling wait in the log tail means the run ended with the approval outstanding, and a resume re-parks a block-mode one and re-registers the approval key so an operator approve still resolves it.
  • approval_resolved: { outcome, reason } with outcome one of granted/denied/ timeout. A grant is emitted at the iteration-head rescope drain (the single re-baseline site); a stop deliberately emits nothing (a stop doesn't resolve the ask).
  • recovery: { class, iter, detail }, emitted once per --resume right after the resume note: how the resumed process classified its predecessor's end. class is one of clean_exit/died_in_baseline/died_in_wide_round/died_mid_turn/died_deciding/ died_in_plan_task/died_awaiting_approval/died_between_iterations; iter is the iteration the interruption touched (0 when not iteration-scoped); detail is a human-readable evidence summary. Purely a record: the loop acts on the in-process classification, never by re-reading this line.

RunIdentity (crucible/src/identity.rs) is the comparability key: two runs' scores are comparable only if it matches. It's a hash-of-hashes (v1:<hex>) over, per component (one unnamed entry for a single-domain run, one per [[component]] for a composite): repo ([repo].url/path) and the workspace's pristine base commit SHA; plus, once per run: the frozen manifest text's hash, a hash over every [[workspace.inject]]'s source content plus destination path, [judge].measure_cmd, and [judge].direction. It's computed once at run setup and doesn't change within a run (a re-scope moves the loop's own Segment fingerprint, a different hash over goal/objective/regime; the two are deliberately independent).


7.1 Admission ledger (state/admissions.jsonl) and the control-bridge id

Every external input into a run (steer, approve, deny, rescope, set-budget, pause, resume, stop, abort) is recorded in a second NDJSON file, state/admissions.jsonl, before it takes effect. Same envelope shape as the session log ({"v":1,"kind":…}, blank/torn lines skipped), two kinds:

  • admitted: { key, seq, ts, input, …payload }. input is the command token, and the payload fields are at the same level ({"input":"rescope","regime":"c=48"}).
  • settled: { key, outcome, ts, note } with outcome one of applied/superseded/ rejected.

Contract, per idempotency key: exactly one admitted, then at most one settled, and the first terminal outcome wins. A key with no settled line is an input the run still owes; --resume re-arms exactly those (an un-delivered steer, a granted-but-undrained re-scope, the live budget cap, the pause level) and closes out the ones a resume overrides (stop/abort become superseded, as do approvals that died before their grant was recorded).

Precedence: admissions.jsonl is authoritative for what an operator asked for; the session log is authoritative for what the loop was waiting on. Where they disagree about an outstanding approval, the ledger wins: a re-scope recorded under the key derived from the ask suppresses the session log's re-park.

Control-bridge commands gain an optional id (string, non-empty, ≤256 bytes) on every mutating object-form command:

{"cmd":"steer","text":"…","id":"pr-comment:owner/repo#7:12345"}

Redelivering the same id with the same payload converges on the original admission ({"ok":true,"cmd":"steer","key":"…","dup":true}, plus "outcome" when it already settled) rather than acting twice; the same id with a different payload is refused ({"ok":false,…,"error":"idempotency conflict: …"}) and nothing is written. Omitting id is exactly the old behavior: the server generates a key and every delivery is a fresh input, so old clients and old servers interoperate unchanged. A stop/abort whose record cannot be written still stops the run and says so with "unrecorded":true; every other command fails closed (no effect) if its admission can't be recorded.

Two consequences worth knowing: the bridge no longer writes STEER.md (a steer command goes straight into the ledger, and the loop's drain reads both the ledger and whatever the file channel accumulated), and "applied" for a steer means delivered into a turn's prompt, not heeded, and not that its iteration was kept.


8. What a domain author writes (the whole surface)

  1. a crucible.toml,
  2. a measure command emitting {valid, score, solved?} (any language),
  3. optionally apply/snapshot/restore commands,
  4. a method prompt + goal,
  5. agent creds in [agent].env.

Everything else (loop, budget, keep/discard, all reporters + remote viewer, steer/stop/resume, session log, escalation, git memory) is the engine's, for free. The litmus test (examples/counter/) exercises items 1, 2, 4 with GitWorld and the command backend, no Rust.


9. CLI surface (non-normative pointer)

The manifest/protocol above is the contract; these subcommands are mechanical consumers of it, noted here tersely so this doc stays the map of what's authoritative:

  • crucible init [--dir <path>]: scaffolds a minimal crucible.toml + a measure stub that always reports the same score. Refuses to overwrite existing files.
  • crucible check --manifest <path>: validates a manifest with no agent turn (parses it, unknown keys are a parse error, resolves every referenced file, runs measure_cmd once to prove the measure contract, runs [judge.selftest] if declared (§1.1), and warns if the gate is reachable by the agent's own edits: a measure_cmd token pointing inside the workspace with no matching frozen inject). Exit nonzero with findings on failure; warnings never fail it.
  • crucible scope --pack <dir> [--issue owner/repo#N | --goal-file <f>] [--force] [--json] (ADR-0012): pipeline over a hand-written domain pack. resolve the goal (from --issue, fetched natively from the GitHub REST API (honors GITHUB_TOKEN/GH_TOKEN and GITHUB_API_URL), or --goal-file, or the pack's own [agent].goal/goal_file), then validate (crucible check, as a library call), then freeze (writes SCOPE.md in the pack dir with the goal, the check outcome, and the pack's RunIdentity digest). Stops at the first failing stage. S2 (propose)/S3 (preflight)/S4 (approval) are listed in SCOPE.md as pending, see ADR-0012. A --goal-file's text is goal framing under the same de-prescription rules as a --issue's title/body: the problem for the pack-designing agent to solve, never a solution. §8's _controls/self-test strip at freeze applies identically regardless of which arm sourced the goal.
  • crucible ps [--namespace <ns>] [--json]: lists loop pods across the cluster, selecting on the app.kubernetes.io/managed-by=crucible label every rendered loop pod carries. ITER ships as - (reserved, see crucible/src/cli/ps.rs's module doc for why it isn't wired up yet).
  • crucible deploy render|apply --manifest <path> --profile <path> [--iterations N] [--max-cost USD] [--no-pin] [--pack [--pack-configmap-name <name>]] [--pr-repo <owner/repo>] [--clusters <path>] [--harness <h>] [--model <m>] [--playbook --max-time <dur> [--param NAME=VALUE]…]: renders (or renders-then-applies) the loop pod + a cross-namespace RoleBinding from the manifest + a deploy profile, image tags resolved to @sha256:… digests. Works for a composite manifest or a plain single-domain one (the latter needs its own [deploy] block naming the build/deploy target, a single domain is a degenerate composite of one). --playbook renders the second mode: the pod runs crucible plan run over the manifest's [workflow] under the given ceilings and parameters instead of the agent loop. It is an explicit flag, never inferred from the manifest, and it conflicts with the loop-only knobs (--iterations, --controller, --pr-repo, --harness, --model). It requires a positive --max-cost and --max-time, and needs neither a [deploy] block nor [agent].sandbox_image (both describe a deployment a playbook never performs). --pack is orthogonal: it controls delivery, --playbook controls the command, and a controller-dispatched playbook passes both.
  • crucible watch-pr --pr <url> [--pr <url> ...] (--control-addr <host:port> | --reseed <path>) [--once] [--poll-secs N] [--bot-user <login>] [--allow-user <login> ...]: watches one or more draft PRs' review comments (--pr repeatable: a kept composite candidate opens one linked PR per component fork) and either steers a live run over its control bridge or appends to a reseed file the next run's first turn reads. --once fetches and exits instead of polling.

Bounded outputs: current state

This page describes what [outputs] and the capability disclosure do, what they do not do, and how they compare to the safe outputs of GitHub Agentic Workflows. The normative specification is RFC-0001 C-OUTPUTS and C-CAPABILITY-DISCLOSURE. This page summarizes the current behavior and the integration contract.

The model

In the usual case, an agent does not change the outside world directly. Each side effect leaves a run as a broker tool call that the agent requests, or as an action that the engine does (publishing a kept candidate, dispatching a build). Some packs need exceptions: an agent-visible credential, added egress, a relayed kubeconfig. Every exception must appear in the disclosure.

In [outputs], the pack declares, before the run starts, how many writes of each kind its runs can make and their targets. The broker and the engine both check this ledger before they act. A write that goes over its count or to a different target fails with the reason, and the run continues. Some reach cannot be counted, for example a raw credential or network access. The pack must declare it instead. That declaration is the exposure. The person who approves the run reads it, and a launch fails if it gives a grant that the exposure does not list.

        frozen [outputs] ────────────┬──────────────┐
                                     v              v
agent ── asks tool ──> broker ──> tally ──ok──> jira/slack/registry/rig
                                     │
engine ─ publish/dispatch ──────> tally ──ok──> draft PR / workflow run
                                     │
                            over / off-target
                                     v
                     refuse, name the bound, log it, run continues

uncountable reach (credentials, egress, relays)
        └──> declared as exposure ──> `crucible plan exposure` emits it as JSON
                  └──> an orchestrator stores, diffs, and shows it at approval

What it does

Declaration. In [outputs], a pack can declare a count for each run and a target for each output kind. The vocabulary is closed, and the engine version defines it: draft-pr, tracker-comment, chat-message, image-push, deploy, workflow-dispatch, gpu-capture. These are manifest errors: a kind outside the vocabulary, a declaration without a count, an unlimited target (* or **, fixed or open), and a scope for any target. An open target names a scope and can bind to a workflow parameter. In that case, the target of the write must be equal to the run value of that parameter. Undeclared kinds get their values from a documented default table in the engine. Every default has a count, and no default has an open target.

Enforcement. The bounds load from the frozen manifest. Two mediation points that the agent cannot reach enforce them:

  • the broker, for the kinds that tools write (tracker-comment, chat-message, image-push, deploy, gpu-capture, and the draft-pr that a request_trace approval opens), through one admission check that every mutating tool uses;
  • the engine, for draft-pr (publish-on-keep) and workflow-dispatch (run_github), through a tally for the run.

Each mediation point enforces the count separately. The broker and the engine each keep a tally against the declared count, so the bound limits each path, not their sum. A pack with draft-pr: 2 allows two publish-on-keep PRs from the engine and two approval PRs from the broker.

If a write would go over its count or to a target outside its scope, the call fails and names the bound. The engine adds an output_refused session row, and the run continues. Publish skips the remaining candidates and completes.

Related hardening. By default, jira_add_comment targets the item that parameterized the run. deploy_candidate validates image_ref against the declared registry prefix, and codegen_build validates the push destination of the loop pod against the same prefix. workflow_dispatch targets must be in an operator org allowlist (FORGE_GITHUB_ALLOWED_ORGS). If the allowlist is not set, all targets fail. deny_endpoints denies a host also when a wildcard allow entry would admit it. crucible check warns when a deny entry hides an allow entry that remains.

Disclosure. Channels that cannot have an effect type are disclosed instead: resolved egress (with built-in entries marked), credentials (with whether the value goes into the agent context and what it authorizes), relay files, broker binary substitution, and whether the pack runs commands outside the sandbox. crucible plan exposure emits the bounds and the disclosure as versioned JSON, without running pack content. crucible check prints them. At run start, the engine rejects a grant from outside the pack (an agent-visible secret binding) if the disclosure does not cover its kind and reach.

Integrating an orchestrator. An orchestrator uses three commands. All of them run offline and have no side effects:

  • crucible --contract-version: before any spend, reject a dispatch target whose version is different from yours.
  • crucible plan exposure --manifest <pack>: the resolved bounds and the capability disclosure as versioned JSON, computed without running pack content. Extract it with the same pinned engine that you dispatch. Store the document and a digest with the registered revision. Compare the digest on every pin bump before you accept it. Show the document where a person approves a run, and bind the approval to the digest that you show. For a mutable pack, extract the document again from the exact content that you launch.
  • crucible check --manifest <pack>: the same resolution in a human-readable format, with validation findings.

The orchestrator also has two obligations:

  • The disclosure must cover each grant that the orchestrator supplies from outside the pack (an agent-visible secret, a mounted credential). If it does not, the orchestrator must reject the grant before dispatch. The engine also rejects it at run start, so a stale stored exposure can fail a launch but cannot widen a run.
  • The engine defaults use the environment that the orchestrator sets (CRUCIBLE_ITEM for the tracker-comment default, the forge variables for image and dispatch targets).

What it does not do

  • Payloads have no limit. Bounds control where mediated writes go and how many there are. They do not control the content. There is no content sanitization: no domain allowlist in a comment body, no mention limits, no dedupe by title, no automatic expiry. The reader of the target reviews the payload.
  • No information-flow control. An agent_visible secret and any allowed channel (a declared output or a disclosed egress entry) make an exfiltration path. The disclosure shows the path to an approver, but nothing stops it at run time. An exposure policy for each secret (a secret that declares which output kinds can be present with it) is designed but not built.
  • No staged or preview mode. You cannot do a dry run of the writes of a pack to see what it would post.
  • Pack code on the trusted side has the reach of the executor. Workflow command and evaluate tasks and world and judge hooks run on the loop pod with its credentials, outside the sandbox and outside all bounds. The disclosure states this. It does not limit it.
  • The rig kubeconfig has full access. A pack that relays a namespace-edit kubeconfig into the sandbox grants every write that the role allows, and does not use the typed broker tools. The disclosure lists it, but it has no bound.
  • Reads are not included. measure, profile, and the fetch tools do not go through admission. Bounds apply only to writes.
  • Asks are not outputs. The operator cap of C-ASKS limits work emission. [outputs] does not.
  • Known enforcement gaps:
    • crucible build --check does not spend or check the dispatch bound, because it dispatches nothing. The engine rejects a repository outside the scope only on the real dispatch.
    • A standalone crucible build outside a run has no session log, so the engine drops its rejection row (best-effort, as for the broker).
    • Each mediation point enforces the draft-pr count separately. The request_trace approval PR uses the tally of the broker, and publish-on-keep uses the tally of the engine. A pack that declares one PR can get one from each.
    • The tracker-comment engine default reads $CRUCIBLE_ITEM. An orchestrator exports it for runs that a tracker item parameterizes. A run without it gets a default that rejects writes.
    • The disclosure of a composite names its own credentials and relays, but it does not include the egress or broker reach of its components.
    • An [agent].env name without a matching capability declaration causes a crucible check warning, not an error.
  • The local backend does not change. backend = "local" runs the harness on the host with the environment of the operator. No sandbox guarantees apply.

Comparison with GitHub Agentic Workflows safe outputs

Common design. Both have a closed vocabulary of typed output kinds, a maximum and target limits for each kind declared next to the workflow, conservative defaults, enforcement at a mediation point that the agent cannot reach, and rejections that do not stop the run. The GH-AW target: "triggering" is the same as the Crucible open-target parameter binding.

Different structure. GH-AW makes the agent job fully read-only and sends every effect through one trusted applier. This works because its workloads are advisory. The Crucible agent must edit code and run commands, so enforcement is split between the broker and the engine. Some channels cannot have a type. Crucible handles them with disclosure and human approval. GH-AW does not need this part and does not have it.

Additional Crucible features. The exposure is a stored artifact with a digest. Pin bumps compare it before acceptance. Approvals bind to the digest that was shown. Launches reject grants that the disclosure does not cover. Egress and credential reach are in the same declaration as outputs. GH-AW has no registration or approval process beyond repository permissions.

Additional GH-AW features. Crucible has no equivalent for the payload features of GH-AW: its sanitization pipeline (body domain allowlists, mention limits, label glob deny-first boundaries, closing-keyword normalization), its staged: true preview mode, and its handler features (dedupe, expiry, grouping).

Conversation content logs (OpenTelemetry GenAI)

This optional telemetry signal exports the full agent conversation of a turn (prompts, completions, reasoning, tool arguments, and tool output) as OpenTelemetry GenAI log records. The records link to the trace of the turn, but they are stored separately. The signal is disabled by default.

The tracing of each turn (the openshell_turn span, with a child span for each tool from the transcript) contains no conversation content. Each tool span keeps only a redacted one-line input hint. It does not keep the full prompts, completions, tool arguments, or tool output. Span attributes have a size limit, and full agent input and output is sensitive. This signal exports that content to a store that you control, linked to the trace by id.

What it emits

After a turn, the engine parses the native session transcript of the agent (the same JSONL that it reads to make the tool spans) into GenAI log records. Each record has the trace_id and span_id of the turn span, so a logs backend (Loki, or an agent-observability tool) can join the conversation to the trace:

Transcript entryLog record
user promptgen_ai.user.message
system promptgen_ai.system.message
assistant turn (text + reasoning + tool calls with full args)gen_ai.assistant.message
the final assistant turn (the completion)gen_ai.choice
tool result (full output, is_error)gen_ai.tool.message

Every record has gen_ai.system (anthropic). If the transcript has the model, the record also has gen_ai.request.model.

Enabling it

Both conditions below are necessary. If one is missing, the engine installs no exporter, and the emit path does nothing and costs nothing, as for the trace exporter:

VariablePurpose
CRUCIBLE_TURN_TRACE_CONTENTThe enable flag (1 / true / on). It is required, because content export reverses the removal of content from spans, so it is never the default.
OTEL_EXPORTER_OTLP_LOGS_ENDPOINTThe OTLP/HTTP logs endpoint. Loki accepts OTLP only over HTTP, but the span exporter to Tempo uses gRPC. This variable is specific to logs, and it overrides the base endpoint. The engine uses it without change as the full URL (for example, http://collector.example:3100/otlp/v1/logs, with no path added).
OTEL_EXPORTER_OTLP_ENDPOINTThe base OTLP endpoint. The engine uses it for logs if no logs endpoint is set. As the OTLP/HTTP convention specifies, the engine adds /v1/logs to it.

Redaction

The engine removes credentials from message bodies with the same code as for the span hints (environment NAME=value secrets, Authorization: headers, URL userinfo). It applies the redaction to each line, so multi-line bodies keep their structure. This variable controls it:

VariablePurpose
CRUCIBLE_TURN_TRACE_REDACTEnabled by default. Set 0 / false / off to disable it. Disable it only if the store is trusted.

Privacy

Send content logs to an access-controlled logs store, not to the shared Tempo. The bodies are as confidential as the raw session transcripts.

The engine emits the OTLP spans and logs above. The MLflow turn-result exporter is part of the controller (crucible-controller/src/runs/mlflow.rs). It is separate from this signal.

Hand-rolled codegen pipelines

This page describes how to run the code-build and GPU-measurement tools of the Crucible broker without the controller or the optimization loop. Use this path to validate the tool contract of a new domain, to test a GPU substrate, or to find the cause of build and measurement failures.

The normal deployment is a loop from a manifest, rendered by crucible deploy.

Current architecture

The broker has the registry credentials and the Kubernetes access. Its caller supplies source edits and a limited set of declared arguments. The caller does not supply build credentials, job specifications, or measurement commands.

flowchart LR
    caller["MCP client or agent"] -->|"codegen_build / measure calls"| broker["crucible-broker"]
    source["Git candidate checkout"] -->|"exact tree"| broker
    broker -->|"buildah build and push"| registry["OCI registry"]
    registry -->|"digest-pinned candidate"| broker
    broker -->|"suspended Job with Kueue label"| cluster["GPU Kubernetes cluster"]
    cluster -->|"result JSON, logs, or trace"| broker
    broker -->|"typed MCP result"| caller

The data path has four invariants:

  1. codegen_build hashes the candidate tree and builds that exact tree.
  2. The registry result is resolved to an immutable digest.
  3. Measurement tools accept only digests produced by codegen_build during the current broker lifetime.
  4. Benchmark, evaluation, and profile commands are loaded from trusted configuration rather than accepted from the caller.

GPU measurement jobs run the candidate image directly. Do not mount the source workspace over the installed tree of the candidate. If you do, the measurement depends on mutable files and not on the recorded image digest.

Prerequisites

The machine or pod running crucible-broker needs:

  • the crucible-broker binary;
  • buildah, git, and tar on PATH;
  • a writable build and log directory;
  • containers-auth JSON credentials for any private base and candidate registries;
  • network access to the OCI registry;
  • Kubernetes credentials for the measurement cluster.

The Kubernetes target needs:

  • Kueue and a configured LocalQueue;
  • nodes that provide the requested nvidia.com/gpu resource;
  • permission for the broker identity to create, inspect, and delete Jobs, read Pods and pod logs, and inspect Kueue Workloads;
  • an image-pull Secret in the measurement namespace when the candidate registry is private;
  • any model-cache or trace-transport PVCs referenced by the broker configuration.

Build the standalone broker from this repository:

cargo build --release -p crucible-broker

For a remote or delegated cluster, validate submission and Kueue admission with the CPU-only sentinel before using a GPU:

target/release/crucible-broker spoke-smoke gpu-east \
  --kubeconfig /path/to/kubeconfig \
  --context gpu-east \
  --namespace crucible-measure \
  --queue crucible-measure

Use --image when the cluster cannot pull the default public sentinel image.

Configuration model

Rendered deployments separate domain configuration from cluster configuration:

SourceOwnsBroker projection
Domain manifest [measure]GPU count, build recipe, frozen commands, objectives, mutable argument domainsBROKER_CODEGEN=1, BROKER_CODEGEN_TOOLS_DEFAULTS
Deploy profile [measure]Namespace, LocalQueue, PVC names, GPU ceiling, source path, delegated clusterBROKER_CODEGEN_* substrate variables
Scenario or run overlayAllowed per-run changes to the domain defaultsBROKER_CODEGEN_TOOLS_OVERLAY

For a hand-rolled deployment, provide the equivalent environment variables directly.

Frozen tool contract

BROKER_CODEGEN_TOOLS_DEFAULTS is a JSON object. The following example is validated by the broker's own configuration test:

BROKER_CODEGEN_TOOLS_DEFAULTS = {
  "gpus": 2,
  "build": {
    "base_image": "registry.example.com/project/base@sha256:0123456789abcdef",
    "src_dir": "/workspace/project",
    "install_cmd": "python -m pip install -e . --no-deps",
    "full_install_cmd": "MAX_JOBS=4 python -m pip install -e . --no-deps",
    "copy_chown": "1000:1000",
    "mutable_kwargs": {
      "mode": ["derive", "full"]
    }
  },
  "benchmark": {
    "command": "python /opt/crucible/benchmark.py --out \"$OUT\"",
    "output_len": 1024,
    "num_prompts": 4,
    "objective": {
      "key": "tpot_ms",
      "direction": "lower"
    },
    "mutable_kwargs": {
      "toggles": {
        "PROJECT_FEATURE": ["0", "1"]
      },
      "reps": {
        "min": 1,
        "max": 3,
        "default": 1
      }
    }
  },
  "lm_eval": {
    "command": "python /opt/crucible/evaluate.py --out \"$OUT\"",
    "objective": {
      "key": "score",
      "direction": "higher"
    },
    "mutable_kwargs": {
      "limit": {
        "min": 32,
        "max": 500,
        "default": 500
      }
    }
  },
  "profile": {
    "command": "python /opt/crucible/profile.py --out \"$OUT\"",
    "trace_ext": "json.gz"
  }
}

Required fields are gpus, build.base_image, build.src_dir, build.install_cmd, benchmark.command, and lm_eval.command. The profile section is optional. Omitting it makes codegen_profile return unconfigured.

Build modes have distinct purposes:

ModeConfigurationIntended use
derivebuild.install_cmdInterpreted or otherwise fast-installing source changes.
fullbuild.full_install_cmdChanges that require compilation.

Only modes listed in build.mutable_kwargs.mode are accepted. derive is the only default mode when no list is declared. Every other optional caller argument is also rejected unless its domain is declared under mutable_kwargs.

BROKER_CODEGEN_TOOLS_OVERLAY, when set, uses the same JSON shape. Its fields override the defaults and unspecified fields continue to come from BROKER_CODEGEN_TOOLS_DEFAULTS.

Standalone substrate environment

Set these values before starting the broker:

VariableRequiredPurpose or default
BROKER_CODEGENyesSet to 1 or true to enable the codegen tools.
BROKER_CODEGEN_TOOLS_DEFAULTSyesFrozen tool-contract JSON described above.
BROKER_CODEGEN_SANDBOX_WORKDIRyes for a local standalone runCandidate checkout used when no live OpenShell sandbox is available.
BROKER_CODEGEN_NAMESPACEyesNamespace for measurement Jobs.
BROKER_CODEGEN_QUEUEnoKueue LocalQueue; defaults to crucible-measure.
BROKER_CODEGEN_MAX_GPUSnoMaximum admitted GPU count; defaults to 2.
FORGE_REGISTRYyesCandidate image repository without a tag.
FORGE_AUTHFILEyesContainers-auth JSON used for build push and digest resolution.
REGISTRY_AUTH_FILEfor a private baseContainers-auth JSON used by Buildah while pulling the base image.
FORGE_STORAGE_ROOTnoBuild staging, logs, and budget state; defaults to /var/lib/forge.
KUBECONFIGdependsKubernetes config for an out-of-cluster broker; in-cluster service accounts use ambient config.
BROKER_CODEGEN_PULL_SECRETfor private candidatesImage-pull Secret in the measurement namespace.
BROKER_BINDnoMCP bind address; defaults to 0.0.0.0:8849.
BROKER_TOKENrecommendedBearer token required by the MCP endpoint.

Optional Job sizing variables are BROKER_CODEGEN_CPU (default 16), BROKER_CODEGEN_MEM_REQUEST (128Gi), BROKER_CODEGEN_MEM_LIMIT (200Gi), BROKER_CODEGEN_SHM_GI (16), BROKER_CODEGEN_DEADLINE_SECONDS (5400), and BROKER_CODEGEN_TTL_SECONDS (86400). Queue wait time is allowed in addition to the Job's active deadline.

Optional volume variables are:

  • BROKER_CODEGEN_MODEL_PVC and BROKER_CODEGEN_MODEL_MOUNT for a read-only model cache;
  • BROKER_CODEGEN_ARTIFACTS_PVC, BROKER_CODEGEN_ARTIFACTS_MOUNT, and BROKER_CODEGEN_ARTIFACTS_DIR for profile trace transport;
  • BROKER_CODEGEN_WORKSPACE_PVC and BROKER_CODEGEN_WORKSPACE_MOUNT for legacy workloads that explicitly require a workspace mount.

Do not set BROKER_CODEGEN_WORKSPACE_PVC for normal measurements. The rendered deployment omits it, so that a workspace volume cannot hide files in the candidate image.

To delegate Jobs to a separate cluster, set BROKER_CODEGEN_KUBECONFIG. Optional companion variables are BROKER_CODEGEN_KUBE_CONTEXT, BROKER_CODEGEN_PROXY_URL, BROKER_CODEGEN_CLUSTER, and BROKER_CODEGEN_CLUSTER_TIER. Without a delegated kubeconfig, the broker uses its ambient in-cluster client or the standard local kubeconfig resolution.

Example startup

Store the JSON object from the tool-contract example in tools.json, then start the broker in the foreground:

export BROKER_CODEGEN=1
export BROKER_CODEGEN_TOOLS_DEFAULTS="$(jq -c . tools.json)"
export BROKER_CODEGEN_SANDBOX_WORKDIR=/path/to/project
export BROKER_CODEGEN_NAMESPACE=crucible-measure
export BROKER_CODEGEN_QUEUE=crucible-measure
export BROKER_CODEGEN_MAX_GPUS=2
export FORGE_REGISTRY=registry.example.com/project/candidates
export FORGE_AUTHFILE=/path/to/containers-auth.json
export REGISTRY_AUTH_FILE=/path/to/containers-auth.json
export FORGE_STORAGE_ROOT=/path/to/writable/forge-state
export KUBECONFIG=/path/to/kubeconfig
export BROKER_CODEGEN_PULL_SECRET=registry-pull
export BROKER_BIND=127.0.0.1:8849
export BROKER_TOKEN="replace-with-a-random-token"
export STORAGE_DRIVER=vfs

target/release/crucible-broker

Run the broker as the foreground process in a container or pod. If a background broker depends on a short-lived shell or sleep process, it can stop while calls are in progress.

Connect an MCP client

The endpoint is streamable HTTP at /mcp. A Claude-compatible client configuration for a broker running on the same host is:

{
  "mcpServers": {
    "crucible": {
      "type": "http",
      "url": "http://127.0.0.1:8849/mcp",
      "headers": {
        "Authorization": "Bearer <random-bearer-token>"
      }
    }
  }
}

For a sandboxed caller, use the address reachable from that sandbox. Crucible-generated configurations use host.containers.internal for the Podman driver and host.openshell.internal for the Kubernetes driver. A custom hostname must also be added to BROKER_ALLOWED_HOSTS on the broker.

The MCP server name controls the agent-visible prefix. The example above exposes tools as mcp__crucible__codegen_build, mcp__crucible__codegen_benchmark, and so on.

Tool reference

ToolArgumentsSuccessful result
codegen_buildmode (derive by default)built with tree_hash, source, digest, mode, cache state, and build-log handle.
codegen_benchmarkbuilt digest; optional toggles, repsmeasured with numeric metrics, objective, log handles, and cache state.
codegen_lm_evalbuilt digest; optional limitmeasured with the configured objective score, log handles, and cache state.
codegen_profilebuilt digestprofiled with a binary trace handle, log handles, and cache state.
codegen_jobsnoneThe broker's in-flight and recent GPU Jobs with Kueue, Pod, lifecycle, and log-handle data.
fetch_loghandle; optional byte offsetA text window with next_offset and total_bytes.
fetch_tracehandle; optional byte offsetA base64-encoded binary window with next_offset and total_bytes.

The normal call sequence is:

  1. Call codegen_build and retain the returned digest.
  2. Pass that digest to codegen_benchmark and codegen_lm_eval.
  3. Call codegen_profile when the contract includes a profile command.
  4. While a GPU call is blocked, use a concurrent MCP request to call codegen_jobs, then tail its log handle with fetch_log.
  5. Read profile artifacts with fetch_trace, base64-decode each window, and concatenate the decoded bytes in offset order.

Benchmark, lm-eval, and profile calls block until their Jobs finish. codegen_jobs keeps an in-memory ring of at most 20 Jobs and reports the newest first. Lifecycle data from the cluster is best-effort. If the live lookup fails, the state is unknown, not an incorrect queued or running state.

Frozen command output

The broker runs configured measurement commands through /bin/sh -c inside the candidate image. It sets OUT to the result path and HF_HUB_OFFLINE=1. Each command must create the file named by $OUT and exit successfully.

Benchmark

The benchmark output must be a JSON object with at least one top-level numeric or boolean field. All such fields are returned as metrics. When the object contains elapsed_time, the broker also derives tpot_ms and tokens_per_s using the configured output_len and num_prompts; a JSON num_requests field overrides the configured prompt count.

Declared benchmark toggles are passed as environment variables. Declared reps is passed as CRUCIBLE_BENCH_REPS.

Language-model evaluation

The lm-eval output must contain a numeric value under the configured objective key, either at the top level or one object level below it. A declared limit is passed as CRUCIBLE_LM_EVAL_LIMIT.

Profile

The profile command writes its binary artifact to $OUT. With an artifacts PVC, the Job writes directly to that volume, and the broker imports and removes the transport file. Without the PVC, the broker transfers the trace through the Job logs. It rejects artifacts larger than 64 KiB and does not return truncated data.

Use fetch_trace, not fetch_log, for the returned trace handle.

Provenance, caching, and budgets

For a Git checkout, codegen_build creates a temporary index, stages all non-ignored changes, and hashes the resulting tree. The build context is exported from that exact tree, so files excluded by Git do not enter the image under the recorded hash. The response identifies whether the source was a live sandbox, local checkout, or legacy non-Git path.

The broker memoizes:

  • builds by tree hash, build mode, and complete build configuration;
  • benchmarks by digest, toggles, and repetition count;
  • lm-eval runs by digest and limit;
  • profiles by digest.

The memo and the set of digests that the broker built are in memory. When the broker restarts, it keeps the log files under FORGE_STORAGE_ROOT, but it loses the cache entries and the digest provenance. Call codegen_build again before you measure.

BROKER_CODEGEN_MAX_CALLS_PER_TURN and BROKER_CODEGEN_MAX_GPU_MINUTES_PER_TURN limit GPU work when Crucible supplies a turn token. Direct standalone calls have no turn token and therefore do not use the per-turn budget.

Result statuses

Codegen tools return a tagged JSON object. Handle these statuses explicitly:

StatusMeaning
built, measured, profiledSuccessful operation.
job_failedBuild or measurement process failed; inspect the returned log handles.
rejected_kwargThe caller supplied an undeclared name, value, mode, or integer range.
unconfiguredThe optional profile tool is not configured.
budget_exhaustedThe current Crucible turn has exhausted its GPU allowance.
disabledCodegen is off or its configuration cannot be finalized.
errorInfrastructure, provenance, configuration, or delegated-cluster failure.

A delegated-cluster reachability error also includes the configured spoke name and tier.

Troubleshooting

SymptomCheck
disabledConfirm BROKER_CODEGEN=1; parse both tool JSON variables; verify every required field and the GPU ceiling.
Build cannot find sourceSet BROKER_CODEGEN_SANDBOX_WORKDIR to a readable checkout, or verify the live OpenShell gateway and sandbox name.
Git reports dubious ownershipAdd the checkout to the broker user's Git safe.directory configuration or align ownership.
Build fails or fills diskRead the build log; use STORAGE_DRIVER=vfs for unprivileged Buildah and provision enough space for one full base-image build.
Base pull, candidate push, or digest pin failsVerify registry reachability, REGISTRY_AUTH_FILE, FORGE_REGISTRY, and FORGE_AUTHFILE.
Measurement Job cannot pullCreate the Secret named by BROKER_CODEGEN_PULL_SECRET in the measurement namespace.
Job remains queuedVerify the LocalQueue, ClusterQueue quota and flavors, GPU resource request, and Kueue Workload status through codegen_jobs.
Job starts but failsTail the returned handle with fetch_log; verify the candidate contains /bin/sh and the frozen command writes valid data to $OUT.
Digest is rejected after restartCall codegen_build again. Provenance is valid only for one broker lifetime.
Profile says unconfiguredAdd a profile section with a non-empty frozen command.
Profile exceeds fallback limitConfigure an artifacts PVC and mount the same claim into the broker at BROKER_CODEGEN_ARTIFACTS_DIR.
Remote spoke is unreachableRun spoke-smoke; verify kubeconfig, context, proxy, credentials, and the reported reachability tier.

Work graphs describes the workflow DSL and the generic work-graph runner. The broker still has the fixed codegen_benchmark, codegen_lm_eval, and codegen_profile tools. The named-job registry in ADR-0017 is only partially implemented.

The codex harness

crucible --harness codex (or [agent].harness = "codex") runs the turn with the OpenAI Codex CLI instead of Claude Code. Everything after the decoder is the same. The turn emits AgentEvent NDJSON, and keep/discard reads the same Result.

[agent]
harness = "codex"

[agent.codex]
# The shared `[agent].model` names a Claude model, so a codex domain overrides it here.
model = "gpt-5.6-sol"
# auto (default), api, or chatgpt
auth = "api"
# Which of the deployment's OpenAI keys to use; unset = the unnamed default key.
api_key = "WORK"

Differences from a claude turn:

  • No session resume. Crucible does not use codex exec resume. The second turn of a logical session fails. It does not start a new session.
  • No OTEL. codex exec exports no metrics. The cost is an estimate from the pricing table and the token usage in the live --json stream, not an otel_summary.
  • Egress. Codex adds chatgpt.com, auth.openai.com, api.openai.com, and ab.chatgpt.com to the sandbox allowlist. These hosts apply only to codex turns. The allowlist of a claude turn does not change.

Auth selection

Crucible supports both Codex login methods. [agent.codex].auth selects the method:

  • auto (default) uses the selected API key if it is not empty. If it is empty, it uses ChatGPT OAuth.
  • api requires the selected API key. It does not use OAuth.
  • chatgpt uses the OAuth flow, also when API keys are present.

[agent.codex].api_key names one of the OpenAI keys of the deployment (uppercase letters, digits, and underscore). If it is not set, the unnamed default key is used. The manifest names a key, not the variable that holds it. The host resolves api_key = "WORK" to OPENAI_API_KEY_WORK, and an unset api_key to OPENAI_API_KEY. A manifest cannot reach other host credentials. An openshell provider that can deliver the key can later replace this resolution, and the manifest does not change.

A Kubernetes deploy profile supplies keys under those names. You can rotate each key separately:

[[secret_env]]
name = "OPENAI_API_KEY_WORK"
secret = "crucible-openai-work"
key = "OPENAI_API_KEY"

[[secret_env]]
name = "OPENAI_API_KEY_PERSONAL"
secret = "crucible-openai-personal"
key = "OPENAI_API_KEY"

To select a key for new turns, change api_key in the manifest, or select a deployment profile with that manifest. Use dedicated project-scoped keys, and rotate their Kubernetes Secrets separately. The engine does not export the selected key into the sandbox environment, but Codex can read it from its seeded auth file.

ChatGPT OAuth: CODEX_CREDENTIALS and the single-refresher rule

Codex authenticates to the ChatGPT backend with a personal subscription, not Vertex. The credential is an OAuth pair that codex login creates on a host with a browser. It is stored at ~/.codex/auth.json.

Setup:

  1. Run codex login on your machine. Then make sure that ~/.codex/auth.json exists.

  2. Give its exact contents to the loop process as the CODEX_CREDENTIALS environment variable. In a cluster, use a secretKeyRef, as for GCLOUD_CREDENTIALS:

    oc create secret generic codex-auth \
      --from-file=auth.json="$HOME/.codex/auth.json"
    
  3. Reference it from the [[secret_env]] of the deploy profile.

At the start of each turn, the loop process does the OAuth refresh_token grant against https://auth.openai.com/oauth/token. It writes the result into the sandbox as $CODEX_HOME/auth.json: the access token, the account id, the id token, and a placeholder refresh token. An environment variable from a provider cannot hold this value. The sandbox sees only an openshell:resolve:env: placeholder that the L7 egress proxy must resolve, and codex connects to the backend over a WebSocket through an L4 tunnel. All four tokens fields must be present. If one is missing, codex ignores the object, runs without authentication, and gets repeated 401 errors.

Single-refresher rule: the refresh token stays in the loop process. Only the loop process does the grant, so the copy of auth.json on the host and the copy in a sandbox cannot compete for a rotation. OpenAI rotates the refresh token on each grant, and an old copy is no longer valid. As a result, a sandbox has a fixed short-lived access token. Codex reads the seeded auth.json at exec, so if a turn lasts longer than the access token, the turn fails with an error. It does not authenticate again.

The loop process writes the rotated refresh token of each grant to $HOME/.config/crucible/codex-credentials.json. On the next grant, that file has priority over CODEX_CREDENTIALS. The environment secret is used only for the first grant. The first rotation makes it invalid, so a new process with a new $HOME needs a new secret. Run codex login again and replace the secret before each pod restart. Two loop processes must not share one credential. Each needs its own codex login.

Also, a refresh token that is not used becomes invalid after about one week. If the grant fails with refresh_token_expired, run codex login again on the host, replace the secret, and delete the state file if the process keeps a home directory.

In auto mode, Crucible uses OAuth if the selected API key is absent or empty. chatgpt selects OAuth explicitly.

The opencode and pi harnesses

crucible --harness opencode or --harness pi (or [agent].harness = "opencode" | "pi") runs the turn with OpenCode or Pi instead of Claude Code. Use them for an inference endpoint that supports only OpenAI Chat Completions. Claude Code uses the Anthropic Messages API. Codex removed its chat wire API in early 2026 and supports only the Responses API. Neither can use such an endpoint, but OpenCode and Pi can. Everything after the decoder is the same. The turn emits AgentEvent NDJSON, and keep/discard reads the same Result.

[agent]
harness = "pi"
model = "qwen-3-8-27b"

Auth and the endpoint

Both harnesses authenticate with an API key from the environment of the loop. The engine relays the key into the sandbox. The environment selects the API of the turn:

EnvironmentEndpoint
OPENAI_BASE_URL set (with OPENAI_API_KEY)that OpenAI-compatible endpoint. CRUCIBLE_INFERENCE_WIRE_API=chat (default) or responses selects the API.
only OPENAI_API_KEY setapi.openai.com
ANTHROPIC_API_KEY set (with optional ANTHROPIC_BASE_URL)Anthropic Messages, at api.anthropic.com or the base URL

With a controller, these values come from the agent binding of the run in CRUCIBLE_INFERENCE. The controller renders the binding from the provider of the launch (ADR-0065). The engine sets the variables in the sandbox from the endpoint, protocol, and key of the binding. A custom provider with the chat_completions protocol becomes OPENAI_BASE_URL and OPENAI_API_KEY. If a turn has neither key, the engine rejects it before the sandbox starts.

The seeded configuration registers the endpoint as a provider named crucible, with the model under it, so the CLI does not use its own model catalog. OpenCode gets an opencode.json with the crucible provider on @ai-sdk/openai-compatible (or @ai-sdk/anthropic), which reads the key through {env:OPENAI_API_KEY}. Pi gets a models.json with the crucible provider on openai-completions (or openai-responses, anthropic-messages), which reads $OPENAI_API_KEY.

What differs from a claude turn

  • No session resume on either harness. The second turn of a logical session fails. It does not start a new session.
  • No OTEL. If the CLI priced the model, the cost is the number from the endpoint. The CLI never prices the crucible provider. In other cases, the cost is an estimate from the pricing table and the token usage in the stream. OpenCode also makes one more model request in each turn to generate the session title. Its export counts this request in the usage of the turn.
  • Egress. Both add api.openai.com to the sandbox allowlist. For a custom base URL, the engine adds its host for each turn, as for codex. The allowlist of a claude turn does not change.
  • Pi has no MCP client. A pi turn cannot reach the provisioning broker, so a pack whose agent needs the broker tools cannot run on pi. OpenCode gets the broker as a remote MCP server in its configuration, as codex does.
  • Skills. OpenCode finds the .claude/skills directory of Claude Code, so the toolbox goes there. Pi finds .agents/skills. The turn passes --approve, so Pi trusts the project-local files of the workspace without a prompt.
  • Reasoning effort. Pi gets the shared [agent].reasoning_effort as --thinking (max becomes xhigh). The OpenCode equivalent (--variant) is specific to each provider, so the engine does not pass it.

OpenCode's transcript is the session export

opencode run --format json copies the event stream of its server to stdout and exits on the idle signal of the session. In a container, that signal can arrive before the last text or step_finish events, so the engine uses the stream only for display. The sandbox command is a bash wrapper. It runs opencode run, then opencode export <session> into /sandbox/.local/share/opencode/crucible-export.json. That export is the transcript of the turn. It is the only source of the result, the token usage, and the tool spans (backfill_required). A missing export is a transcript error, never a $0 success.

The --mode json stream of Pi comes from its own process and is complete. Pi ends the turn from the live stream. Its session file under $PI_CODING_AGENT_DIR/sessions only adds detail to the trace, as for claude and codex.

Sandbox images

The process that opens the socket must be on the egress binary allowlist of the sandbox. OpenShell matches the binary that the kernel resolves. /usr/local/bin/opencode must resolve to the native opencode binary, because the npm launcher does not open the socket. Pi is a node script, so its allowlist has /usr/bin/node and /usr/local/bin/node in addition to /usr/local/bin/pi. The opencode and pi features of the image feedstock of the controller install these binaries.

Agent tool plugins

Every agent turn's tool calls and provider retries pass through a chain of plugins. A plugin can tag a call for the plugins after it, stop the turn, or report when the turn ends. The chain only reads the agent's event stream; it never changes what the agent does or what the run log records.

The chain is declared per pack, in order:

[[agent.tool_plugins]]
plugin = "repeat_guard"
limit  = 25

[[agent.tool_plugins]]
plugin = "classify"

[[agent.tool_plugins]]
plugin = "tool_stats"

Leaving tool_plugins out runs exactly that chain. tool_plugins = [] runs no plugins at all. An unknown plugin name or parameter fails the manifest check and names the plugins that exist.

Plugins

repeat_guard

Stops the turn when the agent goes round the same cycle of tool calls limit times in a row (limit is required, at least 1). A cycle is one call, or two or three calls taking turns. Two calls are the same when their tool, arguments, result and failure all match, so a poll whose output changes counts as progress, and the same command in a different directory is a different call. A call that fails the same way every time is caught too, and so is an agent that runs a build, reads the same error, and runs the build again.

A stopped turn fails as an agent transport failure, which the executor retries in a fresh turn up to its transport retry limit. A wedge is usually a property of one session rather than of the task, so the fresh turn tends to succeed; when retries run out the task fails and a settled join goes on without it. The stop shows in the run log as a tool_plugin error naming each call in the cycle.

The default of 25 comes from 1,198 recorded turns: healthy turns peaked at 14 identical calls in a row (p99 5), and the two wedged turns in the sample reached 549 and 746. Any limit from 15 to 40 separated them. Raise it for a pack whose agents legitimately repeat one call with the same output, such as polling a long build with a status command that prints nothing new.

classify

Tags each call as read, write, exec, build, network, mcp, agent or other, from the tool name and, for shell calls, the programs the command runs. It stops nothing; tool_stats counts the tags. Place it before tool_stats.

tool_stats

At the end of the turn, logs a tool_stats event (and a tracing event) with the turn's tool use:

{
  "calls": 37,
  "retries": 4,
  "longest_fail_streak": 2,
  "tools": {
    "Bash": { "calls": 24, "failed": 3, "ms": 81234 },
    "Read": { "calls": 11, "failed": 0, "ms": 412 },
    "Write": { "calls": 2, "failed": 0, "ms": 35 }
  },
  "classes": { "build": 9, "exec": 15, "read": 11, "write": 2 }
}

ms is the time from a call to its result, so it is measured only for harnesses that report the two separately (Claude Code); the others report 0. retries counts provider retries and longest_fail_streak the longest run of failed calls in a row.

Compared with OpenHands

OpenHands ships a stuck detector in its agent SDK (stuck_detector.py), on by default, that watches the last 20 events since the user last spoke:

PatternOpenHandsrepeat_guard
Same action, same observationstuck at 4stuck at limit (25)
Same action, same errornudge at 3, stuck at 4the call-and-result pair rule, at limit
Two actions alternating with the same observationsstuck at 6a two-call cycle, at limit
Agent messages with no user inputstuck at 3not applicable: a turn has no user
Context window errors in a loopstubbed out (returns false)not applicable

Both compare calls on their content and outcome, not on ids or timing. The differences come from how the agents run:

  • Thresholds. OpenHands' defaults suit an interactive agent a user is watching. A crucible turn runs unattended to completion, and agents repeat themselves more than that legitimately: in the 1,198 recorded turns above, up to 36 of 999 healthy turns had 4 identical calls in a row and up to 7 had 6. (These are upper bounds: the recorded logs carry calls but not their output, and OpenHands also requires the observations to match.) At 25, repeat_guard stops none of those turns and both real wedges.
  • Recovery. OpenHands can add a message to the conversation, so on a repeating error it first nudges the agent to change course, and on a stuck verdict stops the run in a STUCK state. Crucible drives the harness's CLI and cannot speak to a running turn, so it ends the turn and the executor retries the task in a fresh one.
  • Cycles. repeat_guard also catches three calls taking turns, which OpenHands does not. In the recorded turns, two calls alternating peaked at 3 cycles and three-call cycles never repeated, so the same limit of 25 cycles applies to all of them.

What the chain sees

The chain always reads full tool inputs and results, whatever CRUCIBLE_SESSION_TOOL_IO says: when the run log is compact, the engine decodes the agent's stream a second time for the chain only. Every harness (Claude Code, Codex, OpenCode, Pi) marks a failed call with the failed flag on its tool events, from its own error signal.

Stopping a turn

On the local and command backends the engine kills the agent, with its whole process group when the turn runs under a deadline. On the openshell backend it cancels only the agent's exec, so the sandbox is still torn down as usual.

Adding a plugin

A plugin implements ToolPlugin in crucible/src/agent/tool_chain.rs: on_tool and on_retry return Flow::Continue or Flow::Stop with a typed StopReason, and report returns an end-of-turn label and JSON value. Add its manifest shape to ToolPluginSpec in crucible/src/manifest/tool_plugins.rs, and add it to the default chain only with run data that shows it catches something without stopping healthy turns.

The OpenShell fork

Crucible pins openshell-core and openshell-policy to a fork, wseaton/OpenShell, branch crucible/v0.1.5, instead of upstream nvidia/OpenShell. This page records the reasons for the fork, so that you do not have to find them in the Git history.

Reason for the fork

The branch must rebase without conflicts. crucible/v0.1.5 is the upstream release tag v0.1.5 with a small stack of commits that Crucible needs. The module docs in crucible/src/openshell/mod.rs give the result: the control-plane boundary is the native openshell.v1 gRPC API of the gateway, and the shipped gateway, supervisor, and sandbox runtime binaries are built from the exact pinned rev.

The pin follows a release tag, not upstream main. Upstream v0.1.5 has everything that Crucible needs from upstream, so the only difference is the fork-only stack below.

Pin mechanics

There is one rev, and five consumers use it. All of them get it from Cargo.lock:

MechanismWhereWhat it does
Git dependencyroot Cargo.toml (openshell-core and openshell-policy, rev = "…")The source of truth. Cargo.lock records the resolved 40-character rev.
cargo xtask openshell-revxtask/src/main.rsReads the rev from Cargo.lock. CI workflows and just openshell-rev call it.
CRUCIBLE_OPENSHELL_REVcrucible/build.rs → EXPECTED_GATEWAY_REV in crucible/src/openshell/grpc.rsPuts the rev in the binary. The runtime version check warns on a +g<sha> mismatch and fails below MIN_GATEWAY_VERSION.
Image builds.github/workflows/openshell-gateway.ymlBuilds the openshell-gateway, openshell-supervisor, and openshell-sandbox images from the exact pinned rev, with the tag sha-<rev>. Loop images copy the gateway binaries from them. docker.yml fails immediately if one of the three is missing.
Sandbox runtime defaultCluster::sandbox_runtime_image in crucible/src/deploy/profile.rsA deploy profile without sandbox_runtime_image gets ghcr.io/neuralmagic/openshell-sandbox:sha-<rev> for the compiled rev.

The divergence ledger

Fork-only commits on crucible/v0.1.5, relative to upstream v0.1.5 (bdb956f2), checked 2026-10-27:

shaWhatWhy crucible needs itUpstreamable?
8350ed52feat(kubernetes): answer driver-supplied static hosts in the supervisor: the kubernetes driver accepts pod.host_aliases (hostname to IP) in a sandbox's driver_config, validates it, and the network supervisor answers those names before its resolver, with every policy and destination check still applied. Static hosts are driver-supplied, so they answer both name-authorized and IP-constrained lookups, unlike the sandbox's /etc/hosts (upstream #4602)Deployments reach hosts with no public DNS record ([cluster].host_aliases); stock v0.1.5 rejects the key, and sandbox egress is resolved by the supervisor, not by pod hostAliasesYes, no upstream PR yet

Dropped at this pin, relative to crucible/v0.1.2:

  • 46dcbe04, the cherry-pick of f7273e48 (#3973). v0.1.5 contains it.
  • aeeecb7d, the metadata proxy test fixup. It is part of 8350ed52.
  • 50978123, noyalib 0.0.58. v0.1.5 has noyalib 0.0.53, which RUSTSEC-2026-0333 lists as patched.

Dropped at the crucible/v0.1.2 pin, relative to the previous crucible/grpc-base stack:

  • fix(driver-kubernetes): pin ndots=1 on sandbox pods. Not kept. If sandbox DNS lookups fail with the separate supervisor pod, it returns as a new ledger row.
  • feat(providers): add aws credential provider via web identity (bc8342bb). In v0.1.2, the aws_sts_assume_role refresh gets credentials from the ambient AWS identity of the gateway. Crucible now runs the gateway child as the sandbox read role through the projected sts-audience token (AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE, IMDS disabled). The aws-s3 provider assumes that same role again, so the trust policy of the role must admit the role itself. The loop process keeps the publish role. The gateway does not inherit it.

Changes in v0.1.2 that Crucible adapted to

  • Every public request has a workspace_scope. crucible_broker::workspace::scoped sets the default workspace on each request. A test that scans the source fails on any request without it.
  • Provider profiles are import-only. The gateway has no compiled profiles. Crucible ships the google-cloud, aws-s3, and crucible-broker profiles from crucible/src/openshell/provider.rs and imports them before it creates providers.
  • gateway.toml uses schema version 2 (singular compute_driver). The kubernetes driver runs the supervisor as a separate pod, so grpc_endpoint is the IP of the loop pod (with a matching IP SAN in the server certificate). sandbox_runtime_image is a new image. allow_driver_config = true lets the driver configuration of each Crucible sandbox through.
  • The sandbox RBAC adds the supervisor pod lifecycle, Services, bootstrap Secrets, NetworkPolicies, and admission metadata reads, as in the upstream helm chart.

Changes in v0.1.3 to v0.1.5 that Crucible adapted to

  • The gateway's bundled-z3 feature is now vendored-z3 (#3275). openshell-gateway.yml builds with it.
  • KubernetesComputeConfig gained supervisor_otlp_endpoint, supervisor_ocsf_export, supervisor_gateway_name, allowed_pod_annotations, and allowed_pod_labels.
  • gateway.toml sets config_delivery_mode = "push" (#4320): the gateway sends policy changes over the supervisor session instead of waiting for the supervisor's poll. A supervisor without push support keeps polling.

MIN_GATEWAY_VERSION in grpc.rs is 0.1.5. Older gateways reject the config_delivery_mode key in gateway.toml. The protos from v0.1.2 to v0.1.5 only add.

Pending upstream contributions

Changes that would remove the fork:

  • Static hosts (8350ed52). When upstream merges it, the fork is only a release pin.
  • Upload and download RPCs. File transfer still uses the openshell CLI (SSH-tar over the CreateSshSession relay of the gateway), because no RPC supports it. This is one of the two remaining CLI uses that crucible/src/openshell/mod.rs names. With a native upload and download RPC, Crucible could remove the CLI from the turn path.
  • mTLS user auth with the kubernetes driver. The gateway rejects mTLS user authentication with the kubernetes compute driver, so Crucible renders allow_unauthenticated_users = true for gateways with the k8s driver (crucible/src/openshell/gateway.rs). The trust model while this setting exists: transport mTLS with a CA for each pod still checks every connection (require_client_auth). The client certificate is only in the Crucible turn pod and the supervisor pod, never in the agent container, so possession of the certificate is the authorization. Acceptance criterion for the upstream change: Crucible removes the allow_unauthenticated_users setting.

Maintenance rule

Each pin bump and each new fork commit adds or updates a ledger row above, in the same PR that changes Cargo.lock. If this page cannot explain a rev in the lockfile, that is a bug.

How to bump the pin

  1. Rebase the fork branch. Rebase the fork-only stack onto the new upstream tag as a new crucible/<tag> branch. It must rebase without conflicts. If a commit has conflicts, send it upstream or remove it. Push to wseaton/OpenShell.
  2. Update the dependency. Change rev for openshell-core and openshell-policy in the root Cargo.toml, and let Cargo.lock resolve again.
  3. Images build automatically. A change to Cargo.lock starts openshell-gateway.yml, which builds openshell-gateway:sha-<rev>, openshell-supervisor:sha-<rev>, and openshell-sandbox:sha-<rev>. On the first run, docker.yml fails until all three images exist. Then a retry passes.
  4. The version stamp needs upstream tags. The gateway gets its version from git describe --tags --long, and the tags of the fork are old, so the workflow fetches the numeric v* release tags from nvidia/OpenShell before it builds. You do not have to do anything, but if the stamp is 0.0.0, examine this step.
  5. Check the schemas again. Derive UPSTREAM_KUBERNETES_COMPUTE_CONFIG_FIELDS in crucible/src/openshell/gateway.rs again from the KubernetesComputeConfig of the driver, and derive the sandbox RBAC from the upstream helm chart.
  6. Update this ledger (see the rule above). Increase MIN_GATEWAY_VERSION in crucible/src/openshell/grpc.rs if the new pin uses RPCs that older gateways do not have.

JIRA tools (mediated)

The broker contains a native JIRA Cloud client (the shared ujira crate). It gives the sandboxed agent the read and comment functions of that client over the MCP connection of the broker. This is the ADR-0002 mediation pattern applied to JIRA: the agent gets JIRA tools but never has an Atlassian credential. There is no upstream MCP child process. The broker calls the JIRA REST API directly, on the server side.

What the agent sees

The broker has three JIRA tools. Their names have the broker name as prefix, for example mcp__epp-broker__jira_search:

ToolArgsReturns
jira_searchjql, limit (default 25)compact rows: {key, summary, type, status, labels}
jira_get_issueissue_key, raw (default false)a short record that uses few tokens. Set raw=true for the full issue.
jira_add_commentissue_key, comment{id, url}

jira_add_comment is the only write. The agent has no tool to create, transition, edit, or delete. When the broker creates the client, it limits the client to read and comment (crucible-broker/src/jira.rs). The code enforces the scope. It does not depend on the agent. If JIRA is not configured, the tools return {"status":"disabled"}, as build_epp and profile do when their feature is disabled. The tools are always present.

Context hint. The deployment can name its projects with BROKER_JIRA_PROJECTS (comma-separated, for example PROJ,PROJ2). The jira_search tool description then tells the agent to limit JQL to those boards and not to search the whole instance. BROKER_DESC_JIRA_JQL_EXAMPLE and BROKER_DESC_JIRA_KEY_EXAMPLE set the inline examples in the descriptions. If they are not set, the descriptions are generic.

Trust boundary

The Atlassian credentials are in the environment of the broker pod. They are never in [agent.env], so the sandbox never sees a token, as for the build and deploy credentials. The agent can reach only the broker endpoint, which is already on the egress allowlist. The REST calls to Atlassian Cloud use the egress of the loop pod, not of the sandbox, so the sandbox egress does not change.

Enable it (broker pod environment)

EnvMeaning
JIRA_URLThe Cloud site, e.g. https://your-org.atlassian.net.
JIRA_USERNAMEThe account email (Cloud basic auth is JIRA_USERNAME:JIRA_API_TOKEN).
JIRA_API_TOKENThe API token, from a Secret.

If all three are set, the jira_* tools work. If one is missing, they answer disabled. The broker sets the access level to read and comment, whatever the environment or the configuration of the shared crate allows.

RFC-0001: Crucible implementation contract

Version: 0.2.0 | Status: normative | Phase: spec Owners: @Will Eaton


1. Summary


2. Specification

[RFC-0001:C-MANIFEST] Manifest schema and validation (Normative)

The engine must read exactly one manifest per run. Default path is ./crucible.toml, overridable via --manifest.

The manifest directory (dirname of the manifest file) must anchor all config-relative paths.

Required fields: [repo], when present, must contain exactly one of url or path. [repo] may be omitted only by a manifest whose [workflow].type is playbook; every other manifest must carry it. When [judge] is present, [judge].measure_cmd and [judge].direction are required. When [judge] is absent, the run is a task lane.

[repo].ref, when present, names the branch, tag, or commit the repository is cloned at. It MUST be a valid git ref name, short (main, feature/x, v1.2) or fully qualified (refs/heads/main), or a full commit id, and MUST NOT begin with "-", which git would read as an option. A ref that breaks either rule MUST be rejected as a manifest error naming it. A ref a launch supplies is held to the same rules.

When a ref is set, the engine MUST fetch it from the clone source and check it out detached, so that a short or fully qualified branch or tag, a refs/pull/ ref, and a full commit id all resolve. A commit the clone already holds need not be fetched. A ref that cannot be fetched or checked out MUST fail setup naming the ref; the engine MUST NOT run on any other commit, and MUST remove the clone so that no later run reuses it.

[repo.overrides].refs lists the refs a launch may check out instead of [repo].ref (RFC-0002:C-PLAYBOOK-PARAMS). Each entry is a glob over branch and tag short names: * matches within one /-separated segment and ** across segments, and a branch and a tag of the same short name match alike. An entry that is empty, begins with "-", is not a valid glob, or begins with "refs/" other than "refs/pull/" MUST be rejected at manifest validation. A refs/pull/ ref MUST match only an entry that begins with "refs/pull/", so that a broad entry never admits code from a fork. [repo.overrides] MUST be rejected at manifest validation when [repo] has neither url nor path, and beside [workspace].setup_cmd, which replaces the clone.

A launched ref MUST be refused before anything is cloned when [repo.overrides].refs lists no entry, when no entry matches it, or when the manifest lives inside the workspace it targets, since the checked-out ref would then supply its own manifest. A full commit id is matched once cloned instead: the engine MUST keep it only when a branch or tag of the fresh clone that an entry matches contains it, and otherwise MUST refuse the run and remove the clone. A fresh run with a launched ref MUST refuse a workspace that already exists. A resumed run and a rerun start from the commit their run recorded (RFC-0002:C-PLAYBOOK-RESUME, RFC-0002:C-PLAYBOOK-RERUN), not from the ref.

[agent.overrides].allow lists the agent settings a launch may set on the workflow's agent tasks (RFC-0002:C-PLAYBOOK-PARAMS). Its entries MUST be drawn from harness, model, and effort; any other MUST be rejected at manifest validation. [agent.overrides].models, when non-empty, lists the models a launch may name; it MUST be rejected unless allow lists model, and each entry MUST be a model name as RFC-0002:C-PLAYBOOK-PARAMS defines one. With no allow entries a launch sets no agent setting. A composite manifest MUST reject [agent.overrides].

[workspace].inject entries take two forms. A table names src (manifest-relative), dst (workspace-relative), and frozen (default true). A string is shorthand for a table whose dst equals its src and whose frozen is true. A string that names a directory or contains a glob metacharacter must expand to every regular file it matches under the manifest directory, in sorted path order, each becoming its own frozen entry with dst equal to its manifest-relative path. Expansion must not escape the manifest directory, and a string that matches no file must be rejected as a manifest error naming the string.

[world] with no commands must produce GitWorld. Any command given must produce CommandWorld, which layers domain commands on top of git memory.

[judge.selftest], if present, must require both good_cmd and bad_cmd; runs must be >= 1.

[search], if present with wide > 0, must require approaches.len() >= wide and policy_k in 1..=wide.

Unknown manifest keys must be rejected as errors.

A manifest may declare variants as [variant.] tables, each holding top-level manifest tables, with names of 1 to 64 ASCII letters, digits, '-' or '_'. A run reads the base manifest, the document without its variant tables, unless it names a variant; it then reads the base with each top-level entry the variant declares in place of the base entry of the same name, replaced whole, with nothing merged below the top level. A variant must not declare variants of its own. Every declared variant must be parsed and validated whenever the manifest is loaded, whichever is selected, and naming a variant the manifest does not declare must be refused naming the declared ones. A composite manifest declares no variants.

Frozen loading: when the manifest lives inside the workspace it targets, the engine must parse it from the workspace's pristine base commit, not the current working tree. Before any base commit exists (the very first run), the engine must hard-warn and trust the working tree for that run only.

Since: v0.1.0

[RFC-0001:C-SELFTEST] Gate self-test protocol (Normative)

[judge.selftest] declares two controls the gate must tell apart before it is trusted. crucible check must run it pre-loop, never inside a loop iteration.

The protocol must:

  1. Snapshot the pristine workspace.
  2. Restore to pristine, stage good_cmd, measure runs times through the domain's Judge, restore to pristine again.
  3. Same for bad_cmd.
  4. Pass if and only if both controls' readings are all valid and good's mean score is strictly better than bad's per [judge].direction.

The workspace must be restored to pristine on every exit path (pass, fail, or error).

A manifest with no [judge.selftest] must not be an error; crucible check must warn instead.

Since: v0.1.0

[RFC-0001:C-SEARCH] Wide-round search protocol (Normative)

[search].wide > 0 (or --wide N on the CLI, which overrides the manifest) must fan out N independent PROPOSE turns in per-candidate git worktrees before the deep loop starts, one turn per approaches entry biased into its prompt.

Each candidate's diff must be applied (cherry-picked) into the shared main workspace and measured serially (measurement must never run concurrently; only proposal does).

The scored set must be ranked by [search].policy (v1: top-k); the policy_k winners must seed the deep loop.

Session rows from the wide round must carry an additive phase: "wide" field so a consumer can distinguish a wide-round row from a deep-loop row without a wire-shape change. The field must be skip_serializing_if None so a deep-only run's wire bytes are unchanged.

Since: v0.1.0

[RFC-0001:C-WORKFLOW] Scope-authored workflow DSL (Normative)

A scoped pack may include workflow.star beside crucible.toml. It is authoring syntax; scope must compile it to [[workflow.task]] manifest IR before validation and again before freeze. The generated TOML is the runtime authority.

The DSL must accept: assignments, scalar values, lists, dictionaries, list concatenation, conditional expressions, comprehensions, iteration, user-defined functions, and direct calls to the constructors its lane defines. Every lane has agent, command, evaluate, skill, session, prompt_file, schema_file, param, and workflow. A skill task is an agent task whose prompt is assembled rather than written: the instructions come from a SKILL.md the pack ships under a directory the task names, and the arguments the invocation supplies are rendered after them. It is a naming and reuse construct only, and must not widen what a task may reach. The playbook and custom lanes add route, noul, choice, score, and optional, and accept when on any task; RFC-0001:C-ROUTE states their rules. The scored lanes add propose, apply, measure, grade, decide, top_k, and default_autoresearch. A constructor outside the declared lane must not be in scope: naming one must be an unknown-name error at its own location rather than a validation failure after the graph compiles, and a did-you-mean must never offer one. The lane must therefore be readable from the source before the source is evaluated. Starlark's own pure builtins may be reached; a builtin that reaches the filesystem, a process, the network, the clock, or a source of randomness must be absent. The DSL must not provide mutation of frozen values, filesystem access other than prompt_file(), schema_file(), and load(), processes, network, time, or randomness.

An author-supplied source is untrusted input. Compilation must terminate, and no source may abort the process, exhaust the native stack, or panic: a source that exceeds a bound must fail compilation with a diagnostic the scope pipeline can hand back to its author, because that channel is what turns a bad pack into a recoverable round rather than a dead run. The obligation covers the compiler's own traversal of the source as well as the evaluation of it. Parsing, module resolution, argument marshalling, and value allocation must each be bounded in depth and in size, iteration and recursion must execute under a bound enforced during evaluation, and every bound must be checked before the work it bounds is done rather than after.

load() must resolve only within the pack directory and must refuse absolute paths, parent traversal, and any path reaching a file outside the pack through a link. The number of modules loaded and their total size must be bounded, and each bound must be counted as a module is admitted rather than once it returns. Loaded content must reach the generated TOML, which remains the runtime authority and the hashed artifact.

Topology is authorable; authority is not. A workflow must declare type = "autoresearch", "custom", or "playbook". An autoresearch workflow's result must be a decide task sourced from a frozen measure or authored grade, with apply and propose ancestors. A custom workflow has no autoresearch-shape requirement. A playbook workflow runs its graph once and carries no judge; RFC-0002 states the rules particular to it. Universal DAG, source-typing, and operation-capability rules apply to all three.

Tasks marked isolated = True must run concurrently in disposable worktrees; their workspace state must be discarded. Only a task's declared output continues through the graph: its JSON output, and any files it declared under emits_files.

A task may declare emits, the fields its JSON output promises, as a list of field names or as a dictionary from field name to type. A type is one of "string", "integer", "number", "boolean", "list", "object", a non-empty list of distinct labels, which declares a string equal to one of them, or a schema_file(), which declares a value its JSON Schema admits. Labels must be identifiers, as route labels are. "integer" admits a number with no fractional part, and "number" admits any number. Both forms must reach the generated TOML unchanged, and the plan_admitted event must carry each declared field with its type when it has one, and each declared file with its schema when it has one.

schema_file() must resolve only within the pack directory under the confinement load() has, and its size must be bounded and charged to the same budget as prompt_file(). The file must be a JSON Schema of draft 2020-12 that declares no other $schema and references nothing outside itself; anything else must be a compile error at the call. The engine must not fetch a referenced schema. The schema's content, not its path, must be compiled into the plan, so editing it changes the plan. On the wire a schema is its JSON text. An empty or absent emits declares nothing and is never checked.

A passing attempt whose output lacks a declared field, or holds a declared field whose value is not of its declared type, must settle as a measured failure at the producing task: it must not be retried, its note must name the field, and the output must be kept as the evidence. For a schema, the note must give the instance path and message of each of the first few errors and must not repeat the refused value.

An agent task may declare repair = N, with N from 0 to an engine ceiling of 3; a command or evaluate task must refuse it at compile time, because running it again produces the same output. When an attempt of a task with repair would pass but its output lacks a declared field, holds one that is not of its type, or lacks or breaks a declared file (RFC-0002:C-TASK-FILES), the engine must resume the same agent conversation, give it every such note, masked as above, and check the result again, at most N times, before the attempt settles. A repair must not start a new conversation, must run within the attempt's deadline, must count its cost toward the attempt and the run's budget, and must not start once the attempt has spent what the run had left when it was dispatched. A repair is not a retry: the attempt count does not change, and when repairs run out the attempt settles as the measured failure it would have been without them. Each mapped instance repairs on its own. Each repair turn must be recorded on the task's result with its round, its cost, and the notes it was given. A task that settles itself skipped or failed owes nothing.

Validation must check the declared types against every consumer that reads a field, before dispatch. A task mapped over producer.field must name a field its producer declares, and when that field is typed, a "list". A top_k dependency, a grade score source or tiebreak, and a thresholded evaluate that declare emits must declare score, and when it is typed, a "number" or an "integer". An output-decided route reading a question from a source whose emits types that question's field must find every value of the type answerable under RFC-0001:C-ROUTE: a label list whose every label is one of the question's labels or "uncertain", or, for a noul, "boolean". Any other type, "string" included, must be rejected, because it promises nothing about which label arrives. A field declared by a schema must satisfy each consumer exactly, as far as the schema can be read without evaluating it. Its values are bounded by its const, its enum, or a boolean type, read through local $ref and allOf. Mapped over, its top-level type must be "array" and every item must provably be a string, because a mapped instance is named by its item: the items and every prefixItems entry must have type "string", or a const or enum of strings only, directly, through a local $ref or an allOf member, or in every branch of an anyOf or oneOf; a schema that declares no items, or whose items admit anything else, must be rejected naming what it found. Read as a score, its top-level type must be "number" or "integer" and any const or enum must hold numbers only. Read by an output-decided route, its bounded values must all be answers the question accepts, a boolean answering "yes" or "no"; a schema that bounds nothing must be rejected as "string" is. A schema whose top-level type is absent or a list must be rejected wherever a consumer needs a type.

When an output-decided route's source bounds a question's field to finitely many answers, by a label list, "boolean", or a schema as above, a when that names a label the source can never give must be rejected as never answered, naming the label, the source, and the answers it can give. Such a label need not be routed, and otherwise must expand only to labels the source can give. A field declared without a type is checked for presence only.

session = "name" must preserve one logical agent conversation across dependency-ordered tasks and loop iterations. Tasks sharing a session must not be isolated and must be dependency-ordered.

An agent or skill task MAY set harness, model, and effort, and a session MAY declare them for the tasks it holds. A harness or effort that no agent turn can read MUST be rejected when the graph is validated, before any task is dispatched, naming the task, the setting, and the values accepted; values are read without regard to case. This holds whether the graph was compiled from workflow.star, carried inline as [workflow] tasks, or loaded from a plan file. A launch may change these settings per task under RFC-0002:C-PLAYBOOK-PARAMS.

A required task must not depend, through a path of "all"-join edges, on a task declared advisory. Such a graph asserts both that a failure is tolerable and that the work it gates must pass, and no execution of it can produce an honest verdict. Validation must reject it before dispatch, naming both tasks. A required task joining "passed" or "settled" is exempt: it declares that it runs on whatever settled.

A list of tasks must be accepted wherever a list of task names is, so an author never wraps one to pass it. A task constructed in workflow.star but omitted from workflow(tasks = ...) must be a compile error.

Compile errors must carry file:line:col and a did-you-mean suggestion for unknown functions, kwargs, variables, and session names. A diagnostic about a named argument must locate that argument rather than the whole call, and a suggestion must draw on the names the source itself binds as well as on the DSL's own.

Since: v0.1.0

[RFC-0001:C-PATHS] Path resolution (Normative)

method_prompt, goal_file, and toolbox_dir must resolve relative to the manifest directory.

The agent workspace must resolve to manifest_dir / [workspace].dir.

Runtime state (session.jsonl, admissions.jsonl, control.json) must resolve to --state-dir, default manifest_dir/state.

STEER.md must resolve to --steer, default manifest_dir/STEER.md.

ESCALATION.json must resolve to /ESCALATION.json: written by escalate, consumed by the engine post-turn.

The binary's own install location must never be used to resolve anything.

Since: v0.1.0

[RFC-0001:C-COMMANDS] Command protocol (Normative)

Every command (measure_cmd, apply_cmd, snapshot_cmd, restore_cmd, setup_cmd) must be a string executed via sh -c with cwd = the agent workspace, except setup_cmd which must run with cwd = manifest dir (the workspace does not exist yet).

A manifest states the edition of the pack contract it is written against with a top-level edition key; a manifest without one is edition 1. The engine must refuse at parse a manifest whose edition it does not support, naming the editions it supports. Under edition 1, measure_cmd, apply_cmd, snapshot_cmd, restore_cmd, and every workflow command and evaluate task run with the engine's environment and the variables the engine sets for that command; the engine must refuse at parse an edition-1 manifest whose [judge].env, [world].env, or [workflow].env names a variable, naming the table and edition 2, and crucible check and the start of every run must warn that the pack's commands inherit the engine's environment. Under edition 2, measure_cmd, apply_cmd, snapshot_cmd, restore_cmd, and every workflow command and evaluate task must run with an environment holding only PATH, HOME, LANG, TMPDIR, and the run's identity (CRUCIBLE_RUN_NAME, CRUCIBLE_RUN_DISPLAY_NAME, and CRUCIBLE_UI_BASE_URL) as the engine has them, the variables the engine sets for that command (the scores and token this clause names, and a workflow task's name and inputs), and the variables its manifest table declares: [judge].env for measure_cmd, [world].env for apply_cmd, snapshot_cmd, and restore_cmd, and [workflow].env for workflow command and evaluate tasks. Nothing else of the engine's environment may reach them, since a command can be a script the agent was able to edit. PATH is inherited so a command may be a bare installed tool or a workspace-relative script. The run's identity names the run and links to it and grants nothing; the pod token, and the controller URL it authenticates against, must reach such a command only by its table's declaration. A declared entry must be an environment variable name (a letter or underscore followed by letters, digits, and underscores), optionally followed by a single ?. A name without ? is required: the engine must refuse the run before any of these commands runs, naming every required variable its environment does not set. A name followed by ? is optional: the command must receive it when the engine's environment sets it and must run without it when it does not, and its absence must not refuse the run. An entry with ? anywhere but once at its end, and a name a table declares both required and optional, must fail the manifest's parse, naming the entry. setup_cmd runs with the engine's environment in every edition.

measure_cmd is required when [judge] is present. It must inject CRUCIBLE_BASELINE_SCORE, CRUCIBLE_BASELINE_TOTAL, and CRUCIBLE_BEST_SCORE into env when those values are available (absent on baseline measurement). The engine must read the last stdout line that starts with { and parse it as JSON with fields: valid (bool, required), score (number or null), tiebreak (number, optional), solved (bool, optional, default false), note (string, optional), detail (object, optional). A nonzero exit code must force the reading to valid:false regardless of stdout.

apply_cmd, if present, must run after the agent turn and before measure. Nonzero exit must treat the iteration as an invalid candidate (discard).

snapshot_cmd must emit one opaque token on its last stdout line. restore_cmd must receive the token in CRUCIBLE_TOKEN env. snapshot_cmd and restore_cmd must come as a pair.

setup_cmd default when omitted: with [repo] present the engine must git clone [repo] into [workspace].dir and git checkout [ref]; with [repo] absent the engine must create [workspace].dir empty. In every case the engine must then apply [workspace].inject, and the workspace must end up a git repo whose base commit holds the injected files.

Since: v0.1.0

[RFC-0001:C-BUILD-MODE] Build modes (Normative)

Between "the agent edited a file" and "measure_cmd read a score" sits a build step whose shape must be declared per component.

Four modes: no artifact (compile+run in place), no rebuild (config tuning), derive-layer (interpreted sources appended as OCI layer), image (full container build).

derive-layer must require that the base image and push target share a registry. derive-layer must not carry compiled sources.

A compile failure must be distinguishable from a bad score. In image mode, compile error must be returned to the agent as a free retry with no candidate spent.

A Containerfile on the measured path is part of the judge, not part of the solution. If the build recipe lives in the agent's workspace, it must be a frozen = true inject, re-copied before every scored measure.

crucible check should enforce these preconditions before a turn is spent.

Since: v0.1.0

[RFC-0001:C-DECIDE] Decide rule (Normative)

Given a Reading { valid, score, solved, note, detail }, the current best_score, and the manifest direction, the decide rule must be:

keep = valid AND score.is_some() AND (better(score, best_score, direction) OR (score == best_score AND tiebreak_better) OR solved)

better(s, b, lower) = s < b better(s, b, higher) = s > b

tiebreak_better applies only when the reading carries a tiebreak field: better(tiebreak, best_tiebreak, tiebreak_direction). A best with no recorded tiebreak must count as the worst value.

solved must imply keep. A candidate the measure command declares solved must be kept and must terminate the loop even if its score does not strictly beat best. solved must never rescue an invalid reading.

The first valid reading must set the baseline (best_score, and baseline_total = detail.total if present) and must always be kept.

The loop must terminate when a kept iteration is solved, or budget/iterations exhausted, or stop/escalate.

No domain Rust may decide anything. Complex win conditions must be computed inside the measure command using CRUCIBLE_BASELINE_TOTAL and emitting solved.

Since: v0.1.0

[RFC-0001:C-WORLD] World reversibility (Normative)

World::Snapshot = String, opaque to the engine. The engine must only round-trip it back to restore.

GitWorld (default, no [world] commands): snapshot() must stage+commit the workspace and return the commit SHA. restore(sha) must git reset --hard + git clean (excluding .claude/, RESULTS.md). The kept-commit chain is the memory.

CommandWorld (any [world] command given): must always own git memory as above and layer the domain commands. The snapshot token must be the composite "\t". Keep: commit (git half), then run snapshot_cmd and capture its token (domain half), then join. Discard: split, git reset --hard + clean, then run restore_cmd with CRUCIBLE_TOKEN=.

The engine must expose last_commit_sha() (the git half) for kept_shas/publish; the domain half must never be inspected.

The engine's loop body must call only world.snapshot() / world.restore(&snap). It must contain no git/vcs calls and no kubectl.

Since: v0.1.0

[RFC-0001:C-TRANSPORT] Agent transport backends (Normative)

The engine must render a prompt (method_prompt with {{GOAL}}/{{STATUS}}/{{STEER}} filled), hand it + the workspace to an agent that edits the workspace, and must never hand it the Judge.

Three backends, selected by [agent].backend:

local: must run the selected harness directly on the host in the workspace with [agent].env.

openshell: must run the selected harness in a sandboxed pod driven by the OpenShell driver. Two execution environments: the engine and its contract commands always run where crucible runs; only the agent turn is sandboxed. The OpenShell driver must upload the workspace into the sandbox and sync edits back.

command: must run [agent].agent_cmd via sh -c in the workspace as the proposal. This is a deterministic, free proposer (no LLM). It makes the minimal example a fast, deterministic e2e test.

Flipping local to openshell must be config, not code. Reversibility commands (snapshot/restore) must run engine-side regardless of backend.

Since: v0.1.0

[RFC-0001:C-EGRESS] Sandbox egress policy (Normative)

The sandbox must be deny-by-default. Two lists open it: endpoints (host:port:access) and binaries (only these may open a socket).

With inherit_defaults = true (the default), the lists must be appended and de-duplicated to the built-ins (public forges, PyPI, Vertex, Anthropic, agent CLIs). Appending must never remove a built-in.

With inherit_defaults = false, the resolved allowlist must be exactly what the manifest names, binaries included. This is the only way to subtract a default and is required for air-gapped/private-registry runs and contamination control.

The broker endpoint must be auto-appended by the engine when [agent.broker].enabled is true. The engine must first resolve the broker URL, then derive the egress host:port:full entry from that URL's authority. The broker endpoint must be appended regardless of inherit_defaults because it is engine plumbing, not a built-in the domain can subtract. The same holds for the gateway forward of each service a task in that sandbox needs under RFC-0005:C-SERVICE-REACH: the engine must append it regardless of inherit_defaults, and must allow the agent harness's executable to open that connection whatever binaries list the manifest names.

Since: v0.1.0

[RFC-0001:C-WIRE] Session wire format (Normative)

The NDJSON session log (state/session.jsonl) must keep its existing event kinds (start/phase/row/budget/summary/finished) and field names unchanged. The objective label must be written under the JSON key "gate" (now carrying a free-text label). This must not be renamed (keeps --resume, the remote viewer, and published S3 runs loading).

Additive event kinds:

  • identity: the run's RunIdentity, emitted once at setup and again on --resume.
  • shutdown: { outcome, reason }, emitted exactly once as the last line of every run (after finished/summary). outcome must be one of finished/solved/budget/complete/stopped/escalated/stalled/error. finished means the graph or the loop ran out of work; complete means a task declared there was no work left to do and dispatch stopped short of that, the unscored counterpart of solved. The outcome says how dispatch stopped, not whether the run succeeded. A dead stream with no shutdown line means the pod died mid-run.
  • agent_session: { session, action, turn }, emitted before a persistent agent turn. Must not contain the provider cursor or native transcript content.
  • approval_wait: { handle, trace_id, mode }, emitted when the loop reads a pending-provisioning marker. Every approval_wait must be closed by an approval_resolved except on stop-while-parked and process death.
  • approval_resolved: { outcome, reason } with outcome one of granted/denied/timeout.
  • decision_wait: { task, request, evidence_digest, expires_at }, emitted when a human-decided route opens its decision request, and again when a resumed run reopens it. Every decision_wait must be followed by that task's result except on stop and process death.
  • plan_admitted: { plan_version, reason, budget_usd, tasks }, emitted once after the graph is admitted and before any dispatch. Each task carries its name, kind, dependencies, session, needs, required flag, join, and stage. This is the consumer's route to the graph, so no gate label has to encode it. A consumer computing a verdict must skip tasks whose stage is epilogue, which is why the field is on the wire rather than inferable.
  • asks_emitted: { task, asks }, emitted once per task that emitted any, as that task settles. Each ask carries an emitter-supplied key, the workflow it names, and that workflow's parameter values. The key must be rejected on decode, not only on construction, so the wire is where the key rules are enforced rather than a way around them.
  • task_started: { task, attempt, estimate_usd }, emitted by a playbook run before each attempt of a task starts, under the name the attempt's task_result settles. estimate_usd is what a resume charges for the attempt if no later task_result for that task follows it in the same process, so that spend an interruption hid still counts against the cost ceiling (RFC-0002:C-PLAYBOOK-RESUME).
  • recovery: { class, iter, detail }, emitted once per --resume.

RunIdentity is the comparability key: two runs' scores are comparable only if it matches. It must be a hash-of-hashes over: repo URL/path + pristine base commit SHA, frozen manifest text hash, inject content+destination hashes, measure_cmd, and direction.

Since: v0.1.0

[RFC-0001:C-LEDGER] Admission ledger (Normative)

Every external input into a run (steer, approve, deny, rescope, set-budget, pause, resume, stop, abort) must be recorded in state/admissions.jsonl before it takes effect.

Two event kinds: admitted { key, seq, ts, input, ...payload } and settled { key, outcome, ts, note } with outcome one of applied/superseded/rejected.

Per idempotency key: exactly one admitted, then at most one settled. The first terminal outcome wins.

A key with no settled line is an input the run still owes. --resume must re-arm exactly those and close out the ones a resume overrides.

admissions.jsonl is authoritative for what an operator asked for; the session log is authoritative for what the loop was waiting on. Where they disagree, the ledger wins.

Control-bridge commands must accept an optional id (string, non-empty, <= 256 bytes) on every mutating command. Redelivering the same id with the same payload must converge on the original admission. Same id with different payload must be refused. Omitting id must generate a key and every delivery is a fresh input.

A stop/abort whose record cannot be written must still stop the run. Every other command must fail closed if its admission cannot be recorded.

Since: v0.1.0

[RFC-0001:C-SURFACE] Domain author surface (Normative)

A domain author must write:

  1. A crucible.toml manifest.
  2. A measure command emitting { valid, score, solved? } (any language), when [judge] is present.
  3. Optionally apply/snapshot/restore commands.
  4. A method prompt + goal.
  5. Agent credentials in [agent].env.

Everything else (loop, budget, keep/discard, all reporters + remote viewer, steer/stop/resume, session log, escalation, git memory) is the engine's, provided for free.

The litmus test (examples/counter/) must exercise items 1, 2, 4 with GitWorld and the command backend, requiring no Rust.

Since: v0.1.0

[RFC-0001:C-OUTPUTS] Declared output kinds (Normative)

A run's effects on systems outside its workspace divide into mediated writes, performed by the engine or a broker on the agent's request, and open-ended capabilities the agent exercises directly (RFC-0001:C-CAPABILITY-DISCLOSURE). This clause governs the mediated writes.

Every mediated write MUST belong to an output kind drawn from a closed, engine-defined vocabulary. The vocabulary is part of the engine's versioned contract: a kind name, once retired, MUST NOT be reused with a different meaning. A write requesting a kind outside the vocabulary MUST be refused. A vocabulary kind the pack does not declare MUST resolve to a documented engine default, and an engine default MUST carry a count and MUST NOT contain an open target: a pack that ships no [outputs] section gets the conservative posture, not the permissive one. The resolved bounds for a frozen pack MUST be computable without executing pack content, and crucible check MUST print them.

A pack MAY declare bounds for a kind in an [outputs] manifest section. A declaration, once present, MUST carry a per-run count, and, for a kind that addresses a target (a repository, a tracker item, a chat destination, an image registry, a deployment), the target itself. Bounds MUST load from the frozen manifest and MUST be enforced at the mediation point; the agent MUST NOT be able to alter a bound from inside the sandbox.

Where a kind addresses a target, the target MUST come from the resolved declaration, never from an agent-supplied value, unless the declaration explicitly marks the target open. An open-target declaration MUST name a scope narrower than the kind's whole address space; a declaration whose scope admits any target MUST be rejected at manifest validation. A scope MAY bind to a named workflow parameter, and when it does, the target MUST equal that parameter's value for the run; this is how a run fanned out per tracker item confines its writes to the item that parameterized it.

A write that would exceed a count or address a target outside its scope MUST be refused at the mediation point. Every refusal under this clause MUST fail the requesting call naming the violated bound, MUST be recorded on the session log, and MUST NOT by itself terminate the run.

Bounding governs where mediated writes land and how many, not what they say. Payloads remain agent-authored; the reader on the addressed target is the payload's review.

Since: v0.2.0

[RFC-0001:C-CAPABILITY-DISCLOSURE] Opaque capability disclosure (Normative)

Some channels cannot be typed as output kinds because they hand the run open-ended reach: a credential whose value enters the sandbox, egress beyond what the engine's built-ins grant (RFC-0001:C-EGRESS), a relay that materializes host-side secrets into sandbox files, a substitute broker binary, or a mounted credential authorizing writes to an external system such as a cluster namespace. These are capabilities, not outputs, and bounding them by effect is not possible; the contract is disclosure.

A frozen pack MUST disclose every such capability, and the resolved disclosure MUST be computable from the pack without executing pack content. For each capability the disclosure MUST state its reach: for egress, the host, port, and access class in C-EGRESS's terms; for a credential, whether its value enters agent context, remains broker-held, or is held by a service under RFC-0005:C-SERVICE-SECRETS, which external system it authorizes, and at what scope; for a relay or a broker substitution, what it draws from and what it exposes. The agent's own credentials, [agent].env included, are credentials under this clause and MUST appear in the disclosure. The built-in egress allowlist is standing disclosed reach: it need not be re-declared, but the resolved disclosure MUST include it, and with inherit_defaults = false every entry the manifest names is disclosed manifest reach, built-in lookalikes included.

The engine MUST derive what it provisions and what it discloses from the same resolved declaration, so the two cannot diverge, and crucible check MUST print the resolved disclosure.

A capability granted from outside the pack, such as a secret binding supplied at launch, is covered when a disclosed capability of the same kind has reach equal to or broader than the grant's. A grant that is not covered MUST be refused at run start, and the refusal MUST name the grant and the missing disclosure.

Sandbox-resident pack content, skills included, holds no reach of its own: what it can touch is the union of the resolved disclosure and the declared output kinds. Pack-authored commands that execute outside the sandbox (workflow command and evaluate tasks, world and judge hooks) hold their executor's reach instead, within the environment RFC-0001:C-COMMANDS gives them, and the disclosure MUST state that the pack runs commands outside the sandbox whenever it does. Under edition 2 (RFC-0001:C-COMMANDS), each variable a manifest table declares for those commands, optional ones included, is reach they hold, and the disclosure MUST name it together with the table that declares it and mark it optional when it is. Under edition 1 those commands hold the engine's whole environment, and the disclosure MUST state that for each table whose commands run.

Since: v0.2.0

[RFC-0001:C-ROUTE] Typed model decisions and conditional tasks (Normative)

A route task answers a fixed set of questions about the outputs of its dependencies and emits one typed answer per question. The engine owns the decision: no agent turn runs, and the task's output is the engine's record of it. A route must declare exactly one decider. A model decider asks a decision model. An output decider reads the answers from one named dependency's output, and is deterministic given that output. A human decider takes the answers from a person allowed to approve the run, under RFC-0002:C-HUMAN-DECISION.

A route must declare at least one question. Each question has an id, instructions, and a type. A noul question answers "yes" or "no". A choice question answers exactly one of its declared options, of which there must be at least two, all distinct. A score question answers one of its ordered levels under RFC-0001:C-ROUTE-SCORE. Question ids and option labels must be identifiers, and the label "uncertain" is reserved. Questions, options, and thresholds are authored at compile time and must reach the generated TOML; nothing a task emits at run time may add, remove, or rename one, except the options of a dynamic choice, which a dependency supplies under RFC-0001:C-ROUTE-OPTIONS.

The state sent to a model decider is the route's dependency inputs, exactly as any other task would receive them under its join. It is untrusted data. Whatever it contains, the answer to a question must be one of that question's declared labels or "uncertain", and the engine must fail the task rather than forward any other value.

A model-decided route must declare min_confidence in (0, 1]. When the probability the model assigns its chosen label is below it, the engine must record the answer as "uncertain". The task's output must carry, for each question, the resolved label and the full probability distribution the model returned, so the record that resume folds under RFC-0002:C-PLAYBOOK-RESUME is the decision itself and a resumed run never asks again.

An output-decided route must name a task among its dependencies. That task's output must carry, under each question id, one of the question's declared labels or "uncertain" as a string; a noul also accepts a boolean. A missing field, any other value, or any other type must fail the route. When the named task declares emits, validation must reject a question id the declaration omits, and, where the declaration types that field, a type that cannot answer the question under RFC-0001:C-WORKFLOW. The recorded answer carries probability 1 for the label read, and an empty distribution with confidence 0 for "uncertain". An output-decided route makes no network call, spends nothing, and needs no capability.

A human-decided route must declare needs = "human". It alone may ask a multiple choice or a pick, whose options come from a dependency's output, and it must not map over a list. Its request, wait, timeout, and recorded answer are governed by RFC-0002:C-HUMAN-DECISION, and what the approver is shown by RFC-0002:C-DECISION-EVIDENCE.

The engine must reach the decision model through the broker, at an endpoint and credential the controller configures and the workflow cannot name. A model-decided route task must declare needs = "decision", verified before dispatch under RFC-0002:C-PLAYBOOK-CAPS. The request and response follow the decision API the binding's protocol names under RFC-0001:C-INFERENCE. For system_one, the System One decision API: a state object and a map of question id to type, instructions, and options. For decisions, the OpenAI Decisions API: the state serialized as JSON in the text input, each noul asked as a predicate question and each choice as a choice question, named by its question id. Either API answers a noul with a probability and a choice with per-option probabilities, and the engine must derive the label from those probabilities alone, never from a choice or confidence the API reports. A question the API declines to answer must be recorded as "uncertain" with confidence 0 and an empty distribution. A response that omits a question, answers it twice, answers it as another type, names an undeclared option, or carries a probability outside [0, 1] must fail the task. Failure to reach the endpoint is a transport failure and retries as one.

Any task may declare when, a predicate over one question of one route task it depends on, satisfied when that question's resolved label is among the labels the predicate lists. Validation must reject a when that names a task that is not a route, a task outside the declaring task's dependencies, an unknown question, an empty label list, or a label that is neither declared by the question nor "uncertain", each with a diagnostic at the argument and a did-you-mean over the declared names. Every label of a routed question, including "uncertain", must either be listed by at least one when or be named in that question's drop list, so an answer that leads nowhere is one the author wrote down rather than one they forgot. A drop list must name only that question's labels or "uncertain". A question no when refers to is exempt.

A task may declare otherwise in place of answers. The compiler must expand it to the question's labels, "uncertain" included, that no other task's when lists and the drop list does not name, and must write that list to the generated TOML. Tasks declaring otherwise on the same question get the same list. An otherwise that expands to nothing, otherwise with answers, otherwise without when, and an empty answers list must each be a compile error at that argument.

A reviewer under RFC-0002:C-REVISE-LOOP must not declare when: a reviewer runs whenever what it revises does, so the condition belongs on a target, and validation must reject the reviewer's. A target's when is evaluated in every round as any task's is; where the first round dispatches no body task, no round rows are written and the reviewer settles as its join makes it.

A task whose when is not satisfied must settle as not taken, without dispatch and without spend. A when over a route that itself settled not taken is not satisfied. A when over a route that settled any other way without passing is not satisfied either, except that a task joining "all" on that route is blocked by the failure as any dependent is. Not taken is a terminal status distinct from skipped. It must not count against validity, must not short-circuit the run, and must not be reported as a failure. A task joining "all" on a dependency that settled not taken must itself settle not taken, so an untaken branch is untaken to its leaves, unless the task declares that dependency optional. A task joining "passed" or "settled" is dispatched as those joins already define, and is how branches rejoin.

A task joining "all" may declare dependencies optional, so it runs whether or not a conditional task it reads was taken. An optional dependency that settled not taken must satisfy the join as a passing dependency does, and the task must receive no input entry for it; an optional dependency that settled any other way without passing must block the task as any dependency does. Validation must reject an optional dependency that is not among the task's dependencies, one declared twice, one declared by a task whose join is not "all", one the task revises under RFC-0002:C-REVISE-LOOP, and one that can never settle not taken: a task with no when that joins "all" on no conditional task through a dependency it does not declare optional. A required task is required when taken: if its when is satisfied it gates validity as any required task does. The workflow's result task must not be conditional and must not be able to settle not taken through "all" joins on dependencies not declared optional.

A route task may declare over. RFC-0001:C-ROUTE-EACH states how a mapped route decides each element of a list and how a when reads that decision; where it and this clause differ for a mapped route, it governs.

The route and when constructs are admitted in the playbook and custom lanes. An autoresearch workflow must reject both: the keep rule is sourced from a frozen measure, and a model decision must not sit on the path to it.

Since: v0.2.0

[RFC-0001:C-INFERENCE] Injected inference bindings (Normative)

An orchestrator tells the engine where models are reached through one document, the resolved inference bindings, carried as JSON in the environment variable CRUCIBLE_INFERENCE. The document is defined once, in the contract crate both sides compile, and carries a wire version. The engine must reject a document whose version it does not know or that has an unknown field, before it clones, spends, or writes anything, with an error that names the version or the field. Absent or empty, the run has no bindings.

A binding states what the inference is for and how it is reached: a role, a protocol, a model, optionally a URL, and optionally the name of the environment variable that holds its credential. An absent URL means the protocol's own service address: for decisions, OpenAI's https://api.openai.com/v1/decisions. A system_one binding must name one, because system_one has none. The document must never carry a credential's value. The roles are agent, the model an agent turn thinks with, and decision, the model a route task asks under RFC-0001:C-ROUTE. The protocols are messages, chat_completions, responses, system_one, and decisions. A document must hold at most one binding per role. An agent binding must speak messages, chat_completions, or responses; a decision binding must speak system_one or decisions. A URL must be absolute http or https, name a host, and carry no credentials. A credential variable name must be a valid environment variable name and must not be CRUCIBLE_INFERENCE. The engine must reject a document that breaks any of these, naming the binding.

A binding says nothing about delivery. The engine derives it from the role's consumer. Where the engine's own process is the client, as for a decision, the engine must use the binding directly and the credential must not leave the engine. Where the client runs inside a sandbox, as an agent harness does, the engine relays: it must project the binding into the sandbox under the names that client reads, and must add the binding's endpoint to the sandbox's egress policy. Those names are the engine's concern and are not part of this contract; an orchestrator must not be required to know them. A decision binding has no relayed consumer: the engine must not project it, its endpoint, its credential, or any interface that reaches it into any sandbox or task process, so a model decision is made only by a route task under RFC-0001:C-ROUTE.

The decision capability of RFC-0002:C-PLAYBOOK-CAPS is available exactly when the document holds a decision binding. A named credential variable that is unset or empty when its binding is first used must fail that use with an error naming the variable, and must not be treated as an unauthenticated endpoint.

An agent binding is the whole answer for agent turns. Its model is the run's model, over the manifest's agent defaults and over a model the launch named; a task that names its own model keeps it. Its protocol constrains the harness: the harness the launch or the manifest selected is kept when it is a client of that protocol, and otherwise the engine must select the harness that is. Its credential takes the place of any credential the pack's manifest selects, in every authentication mode that manifest names. When an agent binding is present the engine must not consult the process's ambient inference variables. The engine must log each harness, model, or credential a binding replaces. An agent binding with no credential variable authenticates with the deployment's ambient credentials, as a run with no agent binding does. A run with no agent binding reaches its model the way its manifest and the process's ambient environment say, which is how an operator's own shell runs one.

An agent binding may carry a price: four finite, non-negative rates in USD per million tokens, for input, output, cache-read, and cache-write tokens. The engine must reject a price on any other binding, naming the binding. The engine must cost each agent turn of the run this way. With a price, the turn costs its token counts times those rates, whatever model the turn runs and whatever cost the harness reports. Without one, a turn on a model a vendor bills for costs what the harness reports, or its token counts times the engine's own rates for that model when the harness reports nothing. A turn on any other model costs nothing, whatever the harness reports, because nobody bills for it. That cost is what the run's cost ceiling (RFC-0002:C-PLAYBOOK-BUDGET) counts.

Since: v0.2.0

[RFC-0001:C-ROUTE-EACH] Per-element routes (Normative)

A route task may declare over = producer.field with max_fanout, under the rules for over in RFC-0001:C-WORKFLOW: the field must be a list of strings, and the route's instance for an element is named by that element's item, its key. A mapped route decides once per element. Each element's decision is one request to the decision model, transport retries aside, or one read for an output-decided route, and its instance settles on its own: its output is that element's decision in the form RFC-0001:C-ROUTE defines for a route, and an element that fails or transport-fails must not change the decision recorded for any other element. The engine must check the run's cost and wall-clock ceilings before each element's request as it does before any dispatch, and an element not yet asked when a ceiling is exhausted must settle blocked; the cost ceiling blocks only an element whose decision asks the decision model, as RFC-0002:C-PLAYBOOK-BUDGET requires.

Two tasks are aligned when both declare over naming the same producer and the same field. Every edge from a task to a dependency aligned with it is read per element, whatever kind of task either is. Across such an edge the dependent must not expand until the dependency has settled as a node, and must then expand whatever the node's status, unless dispatch has stopped for another reason. Its instance for an element must receive the dependency's instance for the same key in place of the node: under "all" or "passed" that instance's output under the dependency's name, and under "settled" that instance's entry as RFC-0002:C-SETTLED-JOIN defines it, with no per_instance mapping. The instance must apply its join to that instance's status in place of the node's, and must read an absent instance as not taken when the dependency's node settled not taken and as blocked otherwise. An instance joining "all" on an aligned dependency whose instance for the same key settled not taken therefore settles not taken, so an untaken element is untaken to the leaves of its aligned chain as RFC-0001:C-ROUTE requires of a branch.

The state sent for an element must be the route's dependency inputs, read per element across aligned edges, with the element's key added under the reserved key "item". A mapped route must not depend on a task named "item", and validation must reject one that does.

Any mapped task, a route or not, may declare keyed, a list of producer.field references. Each must name a field declared by one of the task's dependencies that is not mapped, and where the declaration types it, as "object" or by a schema whose top-level type is "object"; validation must reject any other, naming the reference, and must reject keyed on a task that does not declare over. For each keyed field, the inputs an instance receives, and for a route the state it sends, must carry in place of the whole field only the field's entry under the instance's key, read from the producer's output whatever the task's join; the producer's other fields are unchanged. A keyed value that is not an object at run time must fail every instance, naming the field, and an absent entry must fail that instance, naming the key and the field. keyed must reach the generated TOML, and the admitted-plan event must carry each task's keyed references as a list of "producer.field" strings, empty for a task that declares none, so a reader can draw which edges are narrowed.

An output-decided route that declares over reads each element's answers from its source per element. Where the source is aligned with the route, the answers are read from the source's instance for the element, under the rules RFC-0001:C-ROUTE states for a source's output. Otherwise the source must not be mapped, and its output must carry, under each question id, an object keyed by element key whose entry for the element is the answer under those rules; validation must reject a source whose emits types a question's field as anything but "object" or an object schema, and an absent entry must fail that element.

A when that names a mapped route must be declared by a task aligned with that route. Validation must reject a when on a mapped route declared by a task that is not mapped, or that maps over a different list, naming both references. Such a when is evaluated per element: the dependent's instance for an element applies the rules of RFC-0001:C-ROUTE to the route's instance for the same key, so it settles not taken, without dispatch and without spend, when that element's resolved label is not among the labels the when lists, and the instances for other elements are unaffected. The rules of RFC-0001:C-ROUTE for drop, otherwise, and label coverage apply to a mapped route's questions unchanged. Several tasks aligned with one mapped route may each declare a when on the same question, and so partition the elements among them by answer. A mapped route may itself declare a when on an aligned route, so it is asked only about the elements that route's answer leaves to it. A when on a route that does not declare over, declared by a mapped task, is evaluated once for the node under RFC-0001:C-ROUTE.

A mapped task's instance that settled not taken must not count as failed. A mapped node with no instances must settle passing. Otherwise it must settle not taken when every instance was not taken, passing when every instance passed or was not taken, and failing otherwise. Its folded output must carry the number of instances not taken beside the numbers that passed and failed, and must carry no output for an instance not taken. The task-result event's fan-out summary must carry the same count under not_taken; a reader that predates it reads a summary without it as zero.

Each element's decision is its instance's recorded output, and an instance settled not taken is terminal. On resume, under RFC-0002:C-PLAYBOOK-RESUME, the engine must fold both, must not ask again for an element that has a recorded decision, and must ask only for the elements that have none.

Since: v0.2.0

[RFC-0001:C-ROUTE-SCORE] Score questions (Normative)

A question may be a score: an ordered list of at least two distinct levels, lowest first, each an identifier with a description, in the order the author declares them. A score is a third question type beside noul and choice, constructed in the DSL by score() in the lanes that admit route under RFC-0001:C-WORKFLOW. Its labels are its levels, and every rule RFC-0001:C-ROUTE states for a choice's options applies to its levels: identifiers, the reserved label "uncertain", when, drop, otherwise, and label coverage. The levels and their order must reach the generated TOML, and nothing a task emits at run time may change them.

A model-decided route asks a score question for a probability per level and must validate the answer as it validates a choice's. The engine must fail the task when every level's probability is 0, and must otherwise normalize the distribution to sum 1. The label is the level with the highest normalized probability, the lowest such level on a tie, and is recorded as "uncertain" when that probability is below min_confidence. The recorded answer must carry, beside the label, confidence, and distribution that RFC-0001:C-ROUTE requires, a number under "score": (Σ p(i) · i) / (n − 1) over the normalized distribution, for level i of n counted from 0, so that 0 is the lowest level and 1 the highest. The engine must compute the score from the distribution alone and must not read a score or confidence the API reports. The score must be recorded whatever the label, including when the label is "uncertain".

Where the decision API the binding speaks has a native question over ordered levels, the engine must ask a score question as that question, with the levels in their declared order. Where it has none, as the System One API has none, the engine must ask the question as a choice over the levels and resolve it as above. Either way the recorded answer must name the form it was asked in under "asked_as", as "score" or "choice". A response that answers a score question as any type other than the one it was asked as must fail the task.

An output-decided route answers a score question from one level label, read as RFC-0001:C-ROUTE reads a choice's; the recorded score is that level's position divided by n − 1, and "uncertain" records no score. A source whose emits types the field must type it as a label list within the levels and "uncertain", or as a schema whose bounded values are among them; "boolean" must be rejected.

Since: v0.2.0

[RFC-0001:C-ROUTE-FILES] Declared files in a route's state (Normative)

A route may declare files, a list of workspace-relative paths. Each path must be declared under emits_files by at least one of the route's dependencies with a JSON Schema (RFC-0002:C-TASK-FILES); validation must reject a path no dependency declares, a path every declaring dependency declares without a schema, and a path declared only by a mapped dependency that is not aligned with the route under RFC-0001:C-ROUTE-EACH, naming the path.

The state sent to the decision model must carry, under the reserved key "files", one entry per dependency that declares a listed path, under that dependency's name, mapping each such path to the parsed content of the file staged for the route. For a route mapped over a list, an aligned dependency's file is its instance's for the same element. A dependency whose file was not staged for the route contributes no entry for that path. A staged file that does not parse as JSON, as a failed dependency's file under a "settled" join may not, must contribute null in its place, and the engine must record the path on the route's result. A route must not depend on a task named "files", whether or not it declares files, and validation must reject one that does. A route that declares no files sends no "files" key.

An output-decided route reads its answers from its source's JSON output and must reject files at compile time, because it asks no model.

Since: v0.2.0

[RFC-0001:C-ROUTE-OPTIONS] Dynamic choice options (Normative)

A choice question may name, in place of its options, a field a dependency of the route emits, constructed in the DSL as choice(options = producer.field). Such a choice is dynamic: its options are read at run time, once for each decision, and only the reference reaches the generated TOML. A model-decided or a human-decided route may ask a dynamic choice, and validation must reject one on an output-decided route. A human-decided route resolves it when it opens its request, and the request carries it as the choice it resolved to under RFC-0002:C-HUMAN-DECISION.

The reference must name a field of one of the route's dependencies, and that dependency must not be mapped. For a route that does not declare over, the field is the option list. For a route that declares over, the field is an object keyed by element, as a keyed field is under RFC-0001:C-ROUTE-EACH, and an instance's option list is the entry under its key, read from the producer's output whatever the route's join and keyed. Validation must reject, naming the reference, a reference to a task outside the route's dependencies, to a mapped task, or to a field the producer's emits omit, and, where the emits type the field, a type other than "list" or an array schema for a route that does not declare over, and other than "object" or an object schema for one that does.

An option list must be a JSON array of at most 64 entries, a bound the engine must check before it reads any entry. Each entry is a string, which is the option's label, or an object with a string "value", which is the label, an optional string "description" the model sees, and no other key. Each label must be an identifier other than "uncertain". An entry whose label repeats an earlier entry's is dropped, and the earlier entry is kept with its description. What remains must hold at least two options. An absent field, an absent entry for the key, a value that is not a list, a list over the bound, an entry of another shape, a label that is not an identifier or is "uncertain", and fewer than two options after duplicates are dropped must each fail that decision before the decision model is asked or the request is opened, with a note naming the field, the key where there is one, and the violated bound or entry. Under RFC-0001:C-ROUTE-EACH such a failure must not change the decision recorded for any other element.

The resolved options are asked in the order the list gives them, exactly as a declared choice's options are under RFC-0001:C-ROUTE, and every rule there for answers, probabilities, min_confidence, and refusals applies to them. The recorded answer must carry, beside what RFC-0001:C-ROUTE requires, the resolved options under "options", each as its label and, where one was given, its description, whatever the answer, "uncertain" and a refusal included, so the record shows what the decider chose among. An answer to any other question must not carry options.

A dynamic choice declares no labels at compile time. A when on one must list no label but "uncertain", and its drop list must name nothing but "uncertain"; validation must reject any other label, saying the question's options arrive at run time. A task may declare otherwise on a dynamic choice. The compiler must expand it to every option the question resolves to, together with "uncertain" when no other task's when lists it and the drop list does not name it, and must write the expansion to the generated TOML as the listed labels and a flag that stands for any option. A when carrying that flag must be satisfied by any resolved label other than "uncertain", and validation must reject the flag on a question that is not a dynamic choice. For label coverage under RFC-0001:C-ROUTE, the options of a routed dynamic choice count as one label, covered only by a when carrying the flag.

Since: v0.2.0


Changelog

v0.2.0 (2026-08-22)

Admit the playbook lane

Added

  • route, noul, choice constructors and the when predicate; not-taken terminal status (C-ROUTE)
  • a route decides from a model or, deterministically, from a dependency's output
  • injected inference bindings, one typed document for every model the engine or its sandboxes reach (C-INFERENCE)
  • otherwise, resolved by the compiler to the answers no other task lists
  • typed emits: a field may declare its JSON type or labels, checked at run time at the producer and at compile time against over, score readers, and output-decided routes (C-WORKFLOW)
  • schema_file(): a field may declare a JSON Schema (2020-12) read from the pack; its content is compiled into the plan and consumers read its top-level type (C-WORKFLOW)
  • repair = N on agent tasks: a passing turn whose output breaks its declaration resumes the same conversation with the masked notes, within the attempt's deadline and the run's budget (C-WORKFLOW)
  • schema-typed fields meet each consumer exactly: string items for over, numeric enums for scores, bounded answers for routes; a when label a typed source can never give is refused (C-WORKFLOW)
  • a route may be human-decided: needs = "human", governed by RFC-0002 (C-ROUTE); session log gains decision_wait (C-WIRE)
  • a route may map over a list and decide each element on its own; a task reads a dependency mapped over the same list per element, a when on a mapped route is read per element, and an instance not taken does not fail its fan-out (C-ROUTE-EACH)
  • keyed: a mapped task may narrow an object field of an unmapped dependency to its own element's entry, and the admitted-plan event carries it (C-ROUTE-EACH)
  • score questions: ordered levels answered by a per-level distribution and recorded with an engine-computed expected position; asked natively where the decision API has an ordered question and as a choice where it has none (C-ROUTE-SCORE)
  • a route may put declared JSON files of its dependencies in the decision model's state (C-ROUTE-FILES)
  • [repo.overrides] and [agent.overrides]: static bounds on the refs and per-task agent settings a launch may change (C-MANIFEST)
  • a choice may read its options from a dependency's field at run time, per element on a mapped route, bounded at 64 and recorded with the answer; a when on it lists only uncertain or declares otherwise (C-ROUTE-OPTIONS)

Changed

  • DSL grammar admits dictionaries, conditionals, comprehensions, iteration, user-defined functions, load() and the pure standard library
  • compilation must be bounded in depth and size and must not abort the process on an author-supplied source
  • workflow type may be playbook
  • a task's declared files continue through the graph alongside its JSON output, isolation notwithstanding
  • a required task may not depend on an advisory one through all-join edges
  • param joins the enumerated constructors
  • a route task may declare over (C-ROUTE)
  • a decision binding is never relayed into a sandbox or task process, so only a route asks the decision model (C-INFERENCE)
  • shutdown outcome gains complete for a run a task ended with no work left
  • a named-argument diagnostic must locate the argument, and suggestions draw on source bindings
  • session log gains the plan_admitted event, so a consumer reads the graph instead of inferring it from a gate label
  • deps() is removed: depends_on already accepts task values, so the wrapper was ceremony
  • the declared lane scopes the constructor namespace, so a playbook cannot name or be offered a scored constructor
  • session log gains asks_emitted, so what a run proposed is auditable apart from what an orchestrator admitted
  • skill joins the enumerated constructors: an agent task whose prompt is assembled from a shipped SKILL.md and the invocation's arguments
  • a decision binding may speak decisions, the OpenAI Decisions API, reached at OpenAI's address when it names no URL (C-INFERENCE, C-ROUTE)
  • the capability a model-decided route needs is decision, not systemone, since it names the role rather than one API (C-ROUTE)
  • C-ROUTE-EACH: the cost ceiling blocks only an element whose decision asks the decision model (RFC-0002:C-PLAYBOOK-BUDGET)
  • C-MANIFEST: [repo].ref follows git's ref-name rules and never begins with -; the clone fetches the ref and checks it out detached, and a ref that does not resolve fails setup instead of running on the default branch
  • C-WORKFLOW: a harness or effort no turn can read is rejected at validation instead of at dispatch
  • C-ROUTE: an all-join task may declare a dependency optional, so one that settled not taken satisfies the join and gives no input entry; C-WORKFLOW adds the optional constructor
  • C-MANIFEST: a manifest may declare [variant.] tables; a run that names one reads its top-level tables in place of the base's, and every variant is validated on load
  • C-COMMANDS: measure_cmd, world hooks, and workflow command and evaluate tasks get PATH, HOME, LANG, TMPDIR, the engine's own variables for them, and what [judge].env, [world].env, or [workflow].env declares; a declared variable the engine lacks refuses the run
  • C-CAPABILITY-DISCLOSURE: each variable a table declares for host-side commands is disclosed with its table
  • C-WIRE: task_started { task, attempt, estimate_usd } is an additive event kind a playbook writes before each attempt
  • options are compile-time except a dynamic choice's, which a dependency supplies (C-ROUTE)
  • C-COMMANDS: a declared NAME? is optional, passed to the command when the engine sets it and left out otherwise; only a required name the engine lacks refuses the run
  • C-CAPABILITY-DISCLOSURE: an optional declared variable is disclosed with its table and marked optional
  • C-COMMANDS: a manifest's top-level edition decides what host commands receive; edition 1, the default, inherits the engine's environment, warns, and refuses an env list at parse; edition 2 is the declared allowlist; an edition the engine does not support fails the parse
  • C-CAPABILITY-DISCLOSURE: under edition 1, each table whose host commands run is disclosed as holding the engine's whole environment
  • C-COMMANDS: the edition-2 base adds the run's identity, CRUCIBLE_RUN_NAME, CRUCIBLE_RUN_DISPLAY_NAME, and CRUCIBLE_UI_BASE_URL; the pod token and the controller URL reach a command only by declaration

v0.1.0 (2026-08-19)

Initial draft

RFC-0002: Playbook workflows

Version: 0.1.0 | Status: normative | Phase: spec Owners: @Will Eaton


1. Summary

[RFC-0002:C-SCOPE] Scope (Informative)

A playbook is a workflow that runs exactly once and produces no score. It exists for work whose value is the work itself: a skill pipeline, a scheduled chore, a discovery sweep. The optimization loop's propose, apply, measure, and decide protocol does not participate, and no frozen judge is constructed.

RFC-0001 governs the engine's manifest, command, world, transport, wire, and workflow-authoring contracts. This RFC adds the playbook lane on top of them and does not restate their universal rules. The two changes this lane needed there landed in version 0.2.0 of that RFC: RFC-0001:C-WORKFLOW admits "playbook" as a workflow type and treats a task's declared files as output rather than as workspace state that isolation discards, and RFC-0001:C-WIRE carries the shutdown outcome an early-completing run reports.

Fan-out over an item set that is not known until a task has run is expressed as one run per item rather than as graph expansion. The graph stays static and fully renderable before any spend; RFC-0002:C-ASKS defines the boundary where new work leaves a run.

Since: v0.1.0


2. Specification

[RFC-0002:C-PLAYBOOK-SURFACE] Playbook author surface (Normative)

A playbook author must write:

  1. A crucible.toml manifest with no [judge] and a [workflow] whose type is "playbook".
  2. The graph: a workflow.star, or the [[workflow.task]] tables it would compile to.
  3. A goal, and a prompt for each agent task.
  4. The commands its command and evaluate tasks run, where it has any (any language).
  5. Agent credentials in [agent].env.

A playbook author must not be required to write a measure command, a judge, a baseline, an apply command, a snapshot or restore command, or a result task. No obligation in this RFC may be conditioned on one of them, and the manifest check must not demand one of a playbook. This is the whole difference from RFC-0001:C-SURFACE: the scored lane's author writes a gate and gets a loop, and the playbook author writes a graph and gets one pass.

Everything else is the engine's, provided for free: dispatch and concurrency, ceilings and per-task deadlines, git memory, the session log and every reporter, steer, stop, resume, publish, and ask emission.

The litmus test (examples/playbook/) must exercise items 1 through 4 on the command backend, requiring no Rust, no judge, and no credentials.

Since: v0.1.0

[RFC-0002:C-PLAYBOOK-LANE] Playbook lane admission and execution (Normative)

A manifest with no [judge] MUST accept a [workflow] whose type is "playbook". A manifest with no [judge] MUST reject every other workflow type, and a manifest with [judge] MUST reject type = "playbook".

A playbook graph MUST NOT contain a task naming an engine operation: an operation the scored loop's orchestrator owns rather than the plan author, namely producing a candidate, applying one, measuring one, grading evidence, deciding keep or discard, and measuring one differentially against another. A task that runs an author-supplied command and grades its own result is not an engine operation and MUST be permitted, because it asserts something about the work rather than advancing a scored loop.

A playbook MUST execute its graph exactly once per run. The engine MUST reject a requested iteration count greater than one rather than silently ignoring it.

A playbook's verdict MUST be invalid when any required task settled failing, was truncated, or was left blocked, and when a cost or wall-clock ceiling was exhausted, whatever else the run reports. Otherwise the verdict MUST be valid when every required task settled passing, and when a task declared early completion. A required task left undispatched by early completion MUST NOT affect the verdict; a required task that had already failed MUST invalidate the run whatever a later early-completion signal says, so that a concurrent task cannot launder a failure into a success. The verdict MUST NOT depend on the outcome of any advisory task or of any epilogue task.

The run MUST exit zero if and only if its verdict is valid, and MUST exit nonzero otherwise. This is a stronger obligation than the task lane's, where exit zero means only that the run completed. The exit code alone does not say why a run ended, so the shutdown outcome MUST distinguish an exhausted graph, an early completion, a failure, an operator stop, and an exhausted ceiling. That outcome MUST be drawn from the vocabulary RFC-0001:C-WIRE defines for the shutdown event; the value that vocabulary carries for early completion is "complete".

A command or evaluate task MAY declare early completion by returning the boolean field "complete" as true in its output, optionally alongside a "reason" string. A task of any other kind MUST NOT end the run this way: its output is written by a model, and a model steered by what it read could otherwise skip every task after it, a reviewer among them. The engine MUST refuse such a declaration, MUST record the refusal on that task's session row, and MUST continue the run as if the field were absent; the refusal does not change the task's own outcome. Both names are reserved, and a task MUST NOT name either in its declared outputs: a declared output must be present on every passing attempt, whereas these appear only on the attempt that ends the run, so declaring them would fail every ordinary run. The engine MUST reject a graph that declares either. On a declaration it accepts, the engine MUST stop dispatching further tasks, MUST record the reason where one was given, and MUST NOT treat undispatched tasks as blocked. Determining that there is nothing to do is a successful outcome, and a playbook that cannot say so has no way to end a scheduled run quietly. The shutdown outcome says how dispatch stopped and the verdict says whether the run succeeded; they are independent axes, so a run whose dispatch stopped on early completion after a required task had already failed records the early-completion outcome and an invalid verdict.

A task's workspace changes MUST be committed to the run's git memory when, and only when, it settles with a passing outcome. A task that failed, was cut short by a deadline, or never settled MUST NOT contribute commits. This is what keeps a run that stopped early from publishing a half-finished tree: publication keys on commits beyond the base, and unsettled work never became one. Git memory does not survive an interruption: a resumed run rebuilds its workspace as RFC-0002:C-PLAYBOOK-RESUME requires, and commits only what it settles itself. A finished run's git memory MUST be kept with its evidence as one commit set per task, so that a rerun can replay it under RFC-0002:C-PLAYBOOK-RERUN.

Every settled task MUST produce exactly one session row identifying the task by name. Rows MUST be written as tasks settle, so a reader tailing the log sees progress live; row order is therefore completion order, which concurrent tasks make distinct from graph order. A row's score MUST be null.

A playbook MUST NOT be required to declare a result task. Its verdict is defined above, so there is no single task whose output stands for the run.

A task declared required = false MUST NOT affect the run's verdict, and its failure MUST block only its dependents. The choice of which tasks are advisory is the author's; the engine MUST NOT infer it.

A task declared stage = "epilogue" MUST be excluded from the main graph, MUST run once after the main graph settles, and MUST NOT change the verdict. It MUST run when the main graph completed, when it failed, and when a task declared early completion. It MUST NOT run when the run was stopped by an operator or ended by an exhausted wall-clock ceiling, and an epilogue task that can spend under RFC-0002:C-PLAYBOOK-BUDGET MUST NOT run when the run ended by an exhausted cost ceiling, evaluated at the moment the main graph settles: continuing to spend after either would defeat the control that ended the run.

An epilogue task MUST NOT depend on a main-graph task. It MUST instead receive the main graph's outcome as the reserved input RFC-0002:C-SETTLED-JOIN defines, which carries the run's shutdown outcome and one entry per settled main-graph task, and MUST be staged with each main-graph task's captured files as that clause requires. This is what makes an epilogue task reachable on the failure path, where dependencies would not have passed.

A playbook MUST carry the task lane's gate label, written under the key RFC-0001:C-WIRE reserves for it. The engine MUST NOT introduce a third gate label; a consumer needing the graph MUST read the plan_admitted event RFC-0001:C-WIRE defines, which a playbook always emits.

Since: v0.1.0

[RFC-0002:C-PLAYBOOK-SHAPE] What the graph expresses (Informative)

A playbook's graph says what runs before what, and what must pass before what runs. The graph is dispatched once, so a task that must poll or wait does that inside its own execution, where the author's command or the agent's own turn loop owns the repetition. The one repetition the graph states is a revise loop under RFC-0002:C-REVISE-LOOP: a reviewer sends a failing verdict back to the tasks it names, which must include every task between them and the reviewer, for a number of rounds fixed before any spend. It is declared on edges that already exist, so it adds no node and no edge, and its bound is visible in the compiled graph. Every repetition the graph states has a bound fixed at compile time. A repair whose bound depends on what a task finds is still one task, and the engine never sees that iteration at all.

This is also why an item set discovered at run time cannot become new nodes. The only two things a run can do with such a set are handle it inside one task or emit it as asks under RFC-0002:C-ASKS, and which one is right turns on whether the items deserve their own budget, isolation, and verdict.

The payoff is that the compiled graph is fully renderable before any spend and identical across runs of the same pack at the same parameters: the node set and the edges between them do not depend on what a task finds. Which of those nodes are dispatched still varies. RFC-0002:C-PLAYBOOK-LANE leaves tasks undispatched after an early completion, and RFC-0002:C-PLAYBOOK-CAPS truncates the plan or skips an advisory subtree when a capability is unavailable. Dispatch order among concurrent tasks is not fixed either. What is fixed is the shape. RFC-0002:C-PLAYBOOK-LANE states the once-per-run obligation; this clause records what follows from it for an author deciding where to put a loop.

Since: v0.1.0

[RFC-0002:C-PLAYBOOK-PARAMS] Workflow parameters and launch schema (Normative)

A workflow source MAY declare its parameters in a params block. The block MUST be the source's first statement and MUST be a literal: it MUST NOT contain a call, an interpolation, or a reference to a variable. The engine MUST be able to read the block without evaluating the source, so that an unevaluated or untrusted source can still be introspected. Only the source a run names may declare parameters; a loaded library MUST NOT.

A declared parameter MUST have one of these types: string, integer, number, boolean, or list of strings. A declaration MAY carry a human-readable description, a default, a required flag, and a value constraint: a pattern for a string, a minimum and maximum for a numeric type, or an enumerated choice set. A string declaration MAY also carry a multiline flag, which tells a launch form to take the value in a text area and MUST NOT change how the value binds; the engine MUST reject it on any other type. A parameter declared required MUST NOT also declare a default.

Parameter values MUST be bound during compilation. The compiled graph MUST contain no unresolved parameter reference, so that the rendered graph shows exactly what will run and the frozen artifact remains fully static. Where a manifest field admits a parameter, it MUST be substituted at the same point, so that no parameter survives into execution unresolved.

A declared parameter name MUST NOT contain a dot; a params block that declares one MUST be a compile error. A supplied name that contains a dot is a launch setting, not a parameter: . sets an agent setting on one agent task, *. on every agent task, and repo.ref the ref RFC-0001:C-MANIFEST checks the repository out at. The agent settings are harness, model, and effort. A task-addressed name splits at its last dot, since a task name may contain one, and ref is valid only as repo.ref. A manifest that declares no workflow MUST refuse every launch setting.

Every launch setting MUST be checked before any task is dispatched, and each refusal MUST name the address. The engine MUST refuse a setting the manifest's [agent.overrides] does not allow; a harness or effort that does not parse, read without regard to case; a model that is not a model name, or that is not listed when [agent.overrides].models lists any; a ref RFC-0001:C-MANIFEST refuses; and an address naming a task that does not exist, runs no agent, or shares a session, because a session runs under one agent config. A model name is 1 to 200 characters of letters, digits, and . _ - : / @ [ ], and does not begin with "-".

An agent setting resolves, from highest to lowest, to the launch's ., the task's own value, including one its session supplies, the launch's *., and then the run's defaults. The engine MUST write launch values onto the compiled tasks, so that the rendered graph and the definition digest RFC-0002:C-PLAYBOOK-RERUN records cover them. A launch that sets every task's harness or model both through the run's own agent options and through *. MUST be refused.

A required parameter with no supplied value MUST be a compile error, and the engine MUST NOT dispatch any task of that plan. A supplied value violating its declared constraint MUST be rejected before compilation.

The engine MUST emit the declared block as a JSON Schema document on request, so that one declaration serves command-line validation, ask validation, and a generated launch form.

A parameter value that did not originate inside the pack MUST reach an agent prompt only inside a region the prompt marks as external input not to be followed as instruction. An inspector reading the rendered prompt MUST be able to tell which spans came from outside the pack. Such a value MUST NOT be convertible to plain text: an operation that would yield it as a string without its marking, such as str(), repr(), %-formatting, .format(), or a string method, MUST be a compile error, and the text any such conversion yields MUST NOT contain the value. A value whose span in a prompt carries the text of either marker MUST be a compile error rather than rewritten, so that an attempt to open or close a region is reported to whoever launched the run.

The engine MUST give every command and evaluate task the values bound at compilation under the reserved input "params": an object with one entry per declared parameter, under the parameter's name, carrying the bound value with its declared type, and an empty object where the source declares no parameters. This lets a command read a value from outside the pack as data, without the pack building it into a command line. A resumed run MUST give each task the same values the interrupted run bound. The engine MUST NOT give "params" to an agent task, whose parameter values reach it only through the marked prompt regions above, and MUST reject a plan that names a dependency "params", as RFC-0002:C-SETTLED-JOIN requires for every reserved input.

Since: v0.1.0

[RFC-0002:C-TASK-FILES] Declared file outputs between tasks (Normative)

A task MAY declare emits_files: the workspace-relative paths its output includes as files. Each declared path MUST be relative, MUST NOT traverse outside the workspace, and MUST NOT reach a file outside the workspace through a link of any kind. A symbolic link is refused per path component; a hard link is not distinguishable by resolving the path, so the engine MUST establish confinement by a means that does not rely on resolution alone.

When a task passes, the engine MUST capture each declared file. A declared file absent after an otherwise-passing attempt MUST convert that attempt to a measured failure at the producing task, MUST NOT be retried, and MUST block its dependents. This mirrors the rule for declared JSON output fields: output drift fails where it happened. A task that settles failing is captured too, under the additional provenance and withholding rules of RFC-0002:C-SETTLED-JOIN, and its set is staged only along the edges that clause names.

A declared file MAY carry a schema_file(), under the rules RFC-0001:C-WORKFLOW states for it. When the task passes, the engine MUST parse the captured copy of such a file as JSON and validate it against the schema. Content that is not JSON, or that the schema does not admit, MUST convert the attempt to a measured failure at the producing task like an absent file: it MUST NOT be retried, its note MUST name the file and follow RFC-0001:C-WORKFLOW's rule for schema notes, and the task's set MUST NOT be staged. A failing attempt's files are not validated. An agent task that declares repair under RFC-0001:C-WORKFLOW is shown an absent or mismatched file as a repair note before its attempt settles.

Captured files MUST be staged into a task's workspace before it is dispatched, for every ancestor on its dependency paths and not only its direct dependencies. A pipeline whose later stages need an earlier stage's artifact is the ordinary case, and requiring each hop to re-emit what it received would reproduce by hand the state files this replaces. JSON outputs keep the narrower direct-dependency rule.

Staged files MUST be namespaced by the producing task's name, so that two producers declaring the same path cannot collide. Staged files MUST be read-only to the consuming task. An instance of a mapped task is named after its node and its item key, which is runtime data: the engine MUST NOT refuse a key for holding a path separator or a dot, and where an instance's name forms the directory its files are captured or staged under, the engine MUST escape the key so that the directory is one path component and distinct keys name distinct directories, writing %, /, , NUL, and a leading . as %25, %2F, %5C, %00, and %2E. Everywhere else, the instance's name, its session-log rows, its inputs, and the matching of folded results on resume and rerun, the key MUST stay raw.

Captured files MUST be staged for a dependent even when the producing task ran in disposable isolation. A declared file is part of a task's output, not part of the workspace state that isolation discards.

The total size of files captured in one run MUST be bounded, the bound MUST be operator-configurable and discoverable before a run starts, and exceeding it MUST fail the producing task naming the bound rather than truncating silently.

emits_files MUST NOT be used as a channel between runs. Material that must outlive its run MUST be referenced by published artifact location instead.

Since: v0.1.0

[RFC-0002:C-PLAYBOOK-CAPS] Task capability requirements (Normative)

A playbook MUST execute on one substrate for its whole life. A task therefore cannot be moved to reach a capability, and privileged or specialized work MUST be reached by asking a mediated broker instead.

A needs declaration on a task MUST name a capability that the run can verify is available before dispatch. Whether a substrate provides that capability directly or a broker provides it on request is not the task's concern.

A required task whose needs is not available MUST truncate the whole plan before dispatch, and the engine MUST report that verdict before any spend. An advisory task in the same position MUST be skipped along with its dependents, leaving the verdict unaffected.

Since: v0.1.0

[RFC-0002:C-PLAYBOOK-COMPOSITE] Judgeless composite domains (Normative)

A composite domain MUST be permitted without [judge] when its workflow type is playbook. A playbook over several repositories combines work, not scores, so a composite MUST NOT be required to carry a judge merely because it has more than one component.

A judgeless composite MUST NOT produce a combined score, and the engine MUST NOT construct one.

A component MAY carry its own publish target. A component whose workspace holds commits beyond the base it started from, and that carries a target, MUST publish one draft pull request; a playbook has no propose-and-apply cycle, so this commit comparison is what stands in for the scored loop's notion of a change. A component with no target MUST publish nothing, which is the ordinary case for a playbook whose output is a review, a report, or a filed ask rather than a diff.

Since: v0.1.0

[RFC-0002:C-ASKS] Work emission (Normative)

A playbook MAY emit asks: descriptions of work for another run to perform. An ask MUST carry a key, the workflow it names, and that workflow's parameter values. An ask's parameter values MUST satisfy the named workflow's declared schema, and the receiving orchestrator MUST reject an ask that does not.

The key MUST be supplied by the emitter and MUST be stable across runs for the same underlying item, so that a receiving orchestrator can recognize a repeat without interpreting the ask's contents. What counts as the same item is the emitter's judgment and cannot be checked by the engine; the obligation is verified by auditing emitted keys on the session log against what they referred to.

A run MUST NOT dispatch an ask it emitted. Admission belongs to the receiving orchestrator, which owns deduplication, exclusion policy, and rate limits; an emitting run therefore cannot widen its own blast radius.

The number of asks one run may emit MUST be bounded, the bound MUST be operator-configurable and discoverable before a run starts, and reaching it MUST fail the emitting task rather than silently dropping the remainder.

Emitted asks MUST be recorded on the session log, so that what a run proposed is auditable independently of what was admitted.

An ask MUST NOT carry file content. Material a receiving run needs MUST be referenced by published artifact location.

Since: v0.1.0

[RFC-0002:C-PLAYBOOK-BUDGET] Cost and time ceilings (Normative)

A playbook MUST run under two ceilings supplied by whatever launched it: a total cost ceiling and a total wall-clock ceiling. A playbook source MUST NOT declare either, so that a pack cannot raise a limit its operator set.

The engine MUST refuse to dispatch any task of a playbook for which either ceiling is missing. Unsupervised scheduled work with no stated limit is the failure this rule exists to prevent, so the refusal MUST come before any spend rather than at the first overrun. A run that ends because a ceiling was exhausted MUST name which one in its shutdown outcome.

A task can spend when its attempt asks a model: an agent task, a route a decision model decides, and the scored loop's candidate-producing turn. A revise loop can spend when any task of its body can, because its rounds dispatch as one unit. Every other task cannot spend, and the engine MUST NOT hold it to the cost ceiling.

The engine MUST check the cost ceiling before every dispatch of a task that can spend, and each lane of a fan-out, whether an instance of a mapped task or a member of a batch of isolated tasks, is a dispatch of its own: the engine MUST admit lanes one at a time as each starts, never a batch as a whole. The ceiling is reached once the run's settled spend, plus what attempts still in flight have reported spending so far, reaches it. Once the ceiling refuses a lane, the engine MUST refuse every later lane that can spend, even where a figure in flight later settles lower. The engine MUST run at most a bounded number of lanes of one dispatch at once, so that what lanes already in flight can carry past the ceiling is bounded by that width. A lane or task not dispatched because the cost ceiling was reached MUST settle blocked, naming the cost ceiling.

The engine MUST NOT terminate an attempt already in flight on cost grounds, so attempts in flight when the ceiling is reached MAY carry the total past it. Once the ceiling refuses a dispatch, or an attempt carries the total past it, the run MUST be invalid, and the engine MUST block every further dispatch and retry of a task that can spend. It MUST NOT block a task that cannot spend: such a task MUST still dispatch under its own join and retry its transport failures, so the run collects and reports the work it already paid for.

The engine MUST terminate every attempt in flight when the wall-clock ceiling is reached, MUST invalidate the run, and MUST block all further dispatch and retries. This is deliberately the opposite of the cost rule, and a reader who pattern-matches from one to the other will get it wrong: an attempt's cost is settled only once it finishes, so killing that attempt would spend the money and learn nothing, whereas a run past its window is over no matter what any task is still doing. The wall-clock ceiling therefore blocks every task, including one that cannot spend.

Separately from the run's ceilings, every attempt of every task MUST execute under a bounded wall-clock deadline: a hung task spends nothing, so the cost ceiling cannot bound it. The run's wall-clock ceiling is both the operator's default deadline and its maximum. A task that declares no limit is bounded by the ceiling alone; a task MAY declare a shorter per-attempt limit, and a declared limit exceeding the ceiling MUST be rejected before any task is dispatched. An attempt's deadline is its task's declared limit counted from the start of that attempt, or the instant the run's wall-clock ceiling falls if that is earlier, so a deadline never extends an attempt past the ceiling. Attempts dispatched together MUST each run under their own deadline.

When an attempt reaches its task's own limit, the engine MUST terminate the attempt together with every process it started and MUST settle it as a measured failure whose note names the limit. That failure propagates like any other failure rather than ending the run directly. The engine MUST NOT retry it: rerunning an attempt that exceeded its limit repeats the time it spent without adding evidence. An attempt the run's wall-clock ceiling terminates instead settles as a failure naming the ceiling, and the run ends on that ceiling as the paragraph above requires.

A wall-clock ceiling reached while an epilogue task is in flight MUST terminate that attempt. Either ceiling reached during the epilogue MUST block further dispatch as the paragraphs above require, and MUST NOT change a verdict already determined by the main graph. The epilogue exists to report on the run, so letting its own spend rewrite the run's verdict would defeat it.

Every ceiling and deadline in force MUST be discoverable by the pack author and the operator before a run starts.

Since: v0.1.0

[RFC-0002:C-PLAYBOOK-RESUME] Resumption (Normative)

A resumed playbook MUST NOT re-dispatch a task that already settled. The engine MUST fold prior task results from the session log and MUST treat them as terminal, feeding their outputs to dependents exactly as a live execution would.

A resumed playbook MUST reconstruct its workspace from the pristine checkout plus the declared file outputs of every folded task that settled passing, staged under the rules in RFC-0002:C-TASK-FILES. Files captured from a task that settled failing MUST NOT be restored into the workspace; they reach a dependent joining "settled" through its staged inputs only. It MUST NOT attempt to reconstruct the workspace as some particular task left it: when concurrent tasks were in flight at the interruption, no such state is well defined, and a task's declared output is the only part of its work the contract ever promised would survive.

Spend and elapsed time recorded before the interruption MUST count against the ceilings on resumption. A crash MUST NOT reset either.

A resumed playbook MUST read the manifest variant under RFC-0001:C-MANIFEST that the interrupted run read, and the engine MUST refuse a resume that names another, because a variant can change what the run reaches without changing its graph.

A resumed run MUST NOT re-dispatch the main graph when the folded results cover every main-graph task, and equally when any folded result declared early completion. Early completion leaves tasks undispatched by design, so a resume that only checked for full coverage would dispatch them again on every restart. It MUST still dispatch any epilogue task that has no folded result, under the conditions in RFC-0002:C-PLAYBOOK-LANE. The run's summary, shutdown, and publication MUST each happen exactly once across the original run and all of its resumptions.

Since: v0.1.0

[RFC-0002:C-PLAYBOOK-RERUN] Rerunning from a task (Normative)

A rerun is a new run of a playbook that names a finished run of the same playbook as its parent and one or more main-graph tasks as its selection. It has its own run id, ceilings, session log, verdict, and publication. The engine MUST refuse a rerun whose parent has not shut down, and one whose selection names a task the current graph does not contain.

The engine MUST rerun every selected task, every task downstream of a selected task, every main-graph task the parent did not settle passing, every main-graph task the parent's graph did not contain, and every epilogue task. Where a rerun task is a member of a revise loop under RFC-0002:C-REVISE-LOOP, the engine MUST rerun every member of that loop. A mapped task is reused or rerun as one node, never per instance. Every other main-graph task is reused.

The engine MUST record, for every task in the graph it admits, a definition digest over the compiled task with its parameters bound and over the definition digests of its dependencies. The engine MUST refuse a rerun in which a reused task's definition digest differs from the digest the parent recorded for it, and MUST name each such task. Editing a task therefore reruns it only when it is selected or downstream of a selection; an edit anywhere else is refused rather than silently reused. The definition covers what the graph declares. A file a task reads from the pack or the workspace is not part of it, and an edit to one MUST NOT by itself refuse a rerun.

A rerun MUST start from the commit its parent's workspace started from, not from the target's current head, and MUST refuse when that commit is unavailable. A checkout holding the same tree is the same start. A workspace with no repository of its own starts from a commit of the pack's injected files alone, so a rerun of one starts from its own. Before dispatching any task, the engine MUST replay onto that commit, in the order the parent settled them, the commits each reused task contributed to the parent's git memory under RFC-0002:C-PLAYBOOK-LANE, and MUST refuse the rerun when any replay fails. Replayed commits are the rerun's git memory, so a rerun publishes them as its own.

The engine MUST treat each reused task's parent result as terminal and MUST feed its output and captured files to dependents exactly as a live execution would, under RFC-0002:C-TASK-FILES and RFC-0002:C-SETTLED-JOIN. The engine MUST refuse a rerun when the parent's session log, captured files, or task commits are unavailable, naming what is missing; it MUST NOT reconstruct a reused task's output any other way.

Each reused task MUST appear in the rerun's session log as a settled row marked reused and naming the run that produced it, which for a task the parent itself reused is the run its parent names. Spend recorded for reused rows MUST NOT count against the rerun's ceilings, and MUST be reported apart from the rerun's own spend.

Since: v0.1.0

[RFC-0002:C-REPORTS] Typed workflow reports (Normative)

A playbook MAY declare an engine-owned report task with a destination kind and an optional result task selector. The selected task MUST be a task in the same graph. Selection MUST NOT create a dependency from the report epilogue to the main graph. After the main graph settles, the report task MUST receive only the selected task's declared JSON output when that task passed, never its prompt, stdout, workspace, undeclared files, or credentials. When the selected task did not pass, the report MUST retain the selected task's terminal status without projecting partial output.

A selected report result MUST be bounded before delivery. The operator MUST configure a maximum encoded size, and exceeding it MUST fail the report task without truncation. Values MUST retain their JSON types through the report snapshot. A report renderer MUST escape text for its destination and MUST NOT interpret result values as destination-native payload fragments.

The Slack destination MUST support a structured card projection containing an engine-authored header, run verdict and cost, the selected result fields, bounded task status context, and a controller-owned run link. The engine MUST construct the destination-native payload; a pack MUST NOT supply raw Slack blocks, attachments, webhook URLs, channels, actions, or identifiers. Delivery failure MUST settle a required report task as failing.

The persisted session result for a report task MUST record whether delivery succeeded but MUST NOT persist the webhook credential. An observer MUST be able to distinguish a delivered report, a rendering failure, an oversized result, and a destination failure.

A report MAY declare a template: a file in the pack rendered into the body of the report. The template MUST be able to read the run's verdict, its cost, and the bounded task status context the card carries. Where the selected task passed, the template MUST receive the same bounded, typed snapshot of its result the card projects; where it did not, the template MUST receive only that task's terminal status. A template MUST NOT be able to read any field the selected task did not declare. The renderer MUST escape every value a template inserts, as it escapes a projected field. The rendered body MUST be bounded by an operator-configured size, and exceeding it MUST fail the report task without truncation.

A report MAY name one declared field of its selected task as its severity field. The engine MUST reject a severity field on a report that selects no task, and one the selected task does not declare. Where the selected task passed and that field holds "good", "warning", or "danger", the Slack destination MUST render the card with the accent that value names, whatever the run's verdict: the accent is the pack's reading of its own result, and the card carries the verdict separately. Where the field holds any other value, and where the selected task skipped or was not taken, the destination MUST render the card neutral. Where the selected task failed, transport-failed, or was blocked, it MUST render the card as "danger". The severity field MUST NOT reach the destination as a payload fragment: it selects one of the engine's accents and nothing else.

Since: v0.1.0

[RFC-0002:C-SETTLED-JOIN] Joining on a settled dependency (Normative)

A task MAY declare join = "settled". The engine MUST accept "all", "passed", and "settled", and MUST reject every other value. A task declaring join = "settled" MUST declare at least one dependency. The engine MUST reject join = "settled" on a reducer task, on the engine-owned report task, and on any task naming an engine operation, whose contracts are defined over the passing set or over a fixed typed context.

The engine MUST dispatch a task joining "settled" once every one of its dependencies has reached a terminal status, whatever that status is: passed, failed, not taken under RFC-0001:C-ROUTE, skipped whether the task declared the skip or the substrate did, transport-failed after its retries, or blocked. A settled join MUST NOT make a dependency's runnability a condition of the dependent's; the engine MUST treat such a task as runnable wherever the substrate satisfies its own capability requirement, whatever its dependencies require.

A settled join governs dependency status and nothing else. The engine MUST NOT dispatch such a task once dispatch has stopped for any other reason, and a task left undispatched by a required task settling other than passing, by an exhausted cost ceiling where it can spend under RFC-0002:C-PLAYBOOK-BUDGET, by an exhausted wall-clock ceiling, or by a truncated graph MUST settle blocked whatever its join. An epilogue task is exempt from the required failure alone, because RFC-0002:C-PLAYBOOK-LANE requires it to run when the main graph failed: a required failure MUST NOT block it whatever its join, and an exhausted ceiling or a truncated graph MUST block it as it blocks any other task. Where dispatch stopped because a task declared early completion, RFC-0002:C-PLAYBOOK-LANE governs: the engine MUST NOT treat an undispatched task as blocked, and a settled join MUST NOT cause it to dispatch.

The engine MUST give a task joining "settled" one entry per declared dependency, under that dependency's name and present for every dependency whatever its status, carrying that dependency's terminal status as the token RFC-0001:C-WIRE defines for the task-result event, the engine's note or null where it recorded none, the dependency's JSON output or null, and whether a file set captured in this run was staged for this consumer. The entry MUST carry the output rather than be the output. For a mapped dependency the entry MUST additionally carry, per instance, that instance's status, note, output, and staged-file flag, and that per-instance mapping MUST be present and empty where the node produced no instances; a consumer distinguishes an empty fan-out from a node that never expanded by the entry's status. The engine MUST NOT wrap a reserved input, and MUST reject a plan that names a dependency after one: the reserved key is written after the envelope is built, so it would overwrite that dependency's entry. The engine MUST give every epilogue task a reserved input named "outcome", built when that task is dispatched, carrying under "exit" the token RFC-0001:C-WIRE defines for the shutdown event's outcome and under "tasks" one entry per settled main-graph task, under that task's name, each entry being the entry a task joining "settled" would receive for that task, per_instance included for a mapped task, with its staged-file flag saying whether that task's set was staged for this epilogue task. This is the input RFC-0002:C-PLAYBOOK-LANE requires an epilogue task to receive.

The engine MUST give a task joining "all" or "passed" each contributing dependency's JSON output directly, and MUST NOT give it an entry for a non-mapped dependency that settled failing, transport-failed, or was blocked. The engine MUST NOT give a task joining "all" an entry for a dependency it declares optional under RFC-0001:C-ROUTE that settled not taken, mapped or not. A mapped dependency's fold MUST continue to reach such a task on exactly the terms it reaches it today, whatever the fold's status: a reducer over a lossy fan-out is the case join = "passed" exists for.

A task that ran and measured a failure MUST retain that attempt's structured output. The engine MUST retain an evaluate task's own graded object, MUST retain a command task's final stdout line where it parses as a JSON object although the process exited nonzero, and MUST NOT retain output from an attempt that ended in transport failure. Where a retained object from a nonzero exit carries a non-empty string field "note", the engine MUST record it as the failure's note after the exit status, in place of the standard error tail, so the task's own reason reaches every reader of the note. Where an evaluate task's retained graded object did not pass and carries such a field, the engine MUST record it as the failure's note in place of the engine's own, followed by the threshold and log handles that note would name. The engine MUST NOT treat a retained failure output as a mapped task's source list: a task fanning out over another task's field MUST fail, naming the absent source, unless that source settled passing.

A task MAY settle itself failing by returning the string field "status" as "fail" in its output. The engine MUST record such a task as failed, MUST retain the returned object as that task's output, MUST record its non-empty string field "note", where present, as the task's note, and MUST NOT retry it. This is the third engine-meaningful value of the field that already carries "skipped"; the engine MUST ignore every other value. The field may narrow a task's settlement and MUST NOT widen it: "fail" and "skipped" MUST settle the task that way whatever its exit status or grade, and "pass" MUST NOT settle passing a task that exited nonzero or whose evaluation did not pass.

Declared JSON output fields and declared file paths are owed by a passing attempt only. The engine MUST NOT check declared fields on a failing attempt, and MUST NOT guarantee a task joining "settled" that a failed dependency's output carries them.

The engine MUST capture a task's declared files when it settles passing and when it settles failing, under the confinement, atomic-publication, file-mode, and run-total-size obligations of RFC-0002:C-TASK-FILES. Capture MUST happen before the task's workspace changes are discarded, and the engine MUST NOT require isolated execution to capture on failure. On a failing attempt the engine MUST capture only paths that attempt itself wrote, determined by comparing each declared path's content in the root the attempt ran in against what that path held there before the attempt started, and MUST treat a declared path whose content did not change as absent: a file an earlier task left in the workspace is that task's evidence, not this one's, whether or not the workspace's version control can see it. A declared path that is absent, is not a regular file, or was not written by a failing attempt MUST NOT change that task's status; the engine MUST publish none of the set, MUST remove any set published for that task by an earlier attempt or run, and MUST record the capture problem with the failure note. Where an absent declared path converts an otherwise-passing attempt into the measured failure RFC-0002:C-TASK-FILES requires, the engine MUST retain that attempt's structured output as the failure's. Where the run-total size bound is exceeded by an attempt that has already failed, the engine MUST enforce it by publishing none of the set and recording the bound with the failure note. The engine MUST NOT capture declared files from a task that skipped, that transport-failed, or that was never dispatched, and MUST remove any set such a task published in an earlier run.

The engine MUST stage the declared files of a task that settled failing only into a task joining "settled" that declares it as a dependency and, for a main-graph task, into every epilogue task, and MUST NOT stage them into any other task. The engine MUST stage every settled main-graph task's complete set, whether that task passed or failed, into every epilogue task under that task's name, as RFC-0002:C-TASK-FILES stages an ancestor's, because an epilogue task cannot declare a main-graph dependency to receive it through. The engine MUST stage a passing ancestor's declared files under the rules of RFC-0002:C-TASK-FILES unchanged. A staged set MUST be complete; the engine MUST NOT stage a partial set. One instance of a mapped producer MUST be staged under the instance's own name, its key escaped as RFC-0002:C-TASK-FILES states.

Capture is not commit. A task that settled other than passing MUST NOT contribute a commit to the run's git memory, and its undeclared workspace changes MUST be discarded, as RFC-0002:C-PLAYBOOK-LANE requires. A captured failure set MUST NOT make its producer a passing dependency, MUST NOT satisfy a join of "all" or "passed", and MUST NOT affect the run's verdict.

A resumed run MUST treat a folded non-passing result as satisfying a settled join exactly as a live result does, and MUST NOT re-dispatch the task to regenerate its evidence. Captured sets MUST remain in engine-owned run state, and the engine MUST NOT restore a non-passing task's captured files into the workspace. Where a captured set did not survive the interruption the engine MUST still dispatch the consumer, giving it that dependency's status and output, or an epilogue task that task's outcome entry, reporting no staged files, and MUST NOT re-dispatch the producer.

The admitted-plan event MUST carry "settled" under the key it already carries a task's join, and the engine MUST NOT add a companion field for it; a consumer MUST treat an unrecognised join token as opaque rather than inferring readiness from it. The task-result event MUST NOT change shape: a failing task's retained output MUST travel in the field that already carries a passing task's.

Since: v0.1.0

[RFC-0002:C-REVISE-LOOP] A reviewer's bounded send-back (Normative)

A task MAY declare revise, naming one or more tasks, together with max_rounds. The named tasks are the targets and the declaring task is their reviewer; the targets and the reviewer together are the loop's body. The engine MUST reject revise without max_rounds and max_rounds without revise, MUST reject max_rounds outside 2 to 5 inclusive, MUST reject a revise that names no task or names one task twice, and MUST reject a target from which the reviewer is not reachable through dependencies. The engine MUST reject a body that omits a task lying on a dependency path between two body tasks: a round re-runs the body, and a task between two body tasks outside it would read a result a later round replaces. The engine MUST reject a loop in which any body task is anything other than an agent, command, or evaluate task, or maps over a list. The engine MUST reject a task that is in two bodies, and a reviewer that is itself a target: loops do not nest or chain. The engine MUST reject revise in every workflow type other than "playbook". The name "revision" is a reserved input under RFC-0002:C-SETTLED-JOIN, and the engine MUST reject a dependency named after it.

The engine MUST NOT dispatch the loop's first round until every dependency outside the body of every body task has settled, and MUST NOT dispatch a task outside the body that depends on a body task until every body task has settled under its own name. Where any body task cannot run on the substrate, the engine MUST dispatch the body's tasks as tasks outside any loop and MUST settle each one that cannot run as RFC-0002:C-PLAYBOOK-CAPS requires.

A round dispatches the body's tasks one at a time in dependency order, each under its own join and when against this round's results for the body and the settled results outside it. A body task whose join those results do not satisfy MUST settle blocked for that round, and one whose when excludes it MUST settle not taken for that round, as any task would. The engine MUST NOT begin another round unless the reviewer's round settled failing and fewer than max_rounds rounds have run, and MUST begin one when both hold and no ceiling is exhausted. A reviewer round that passed, skipped, transport-failed, was blocked, or was not taken ends the loop. Before every round after the first, and before every dispatch within a round that the task's join and when would allow, the engine MUST check the cost and wall-clock ceilings. An exhausted ceiling ends the loop and halts the run as it would halt any dispatch. Within a round, a body task not dispatched for it MUST settle blocked on that ceiling; a loop ended by a ceiling between rounds leaves each body task settled as its last round did. An epilogue loop outlives a required failure in the main graph as any epilogue task does.

From the second round on, the engine MUST give each target the reserved input "revision", carrying under "round" the round about to run, under "max_rounds" the declared bound, under "reviewer" the reviewer's name, and under "review" the reviewer's previous round as the entry RFC-0002:C-SETTLED-JOIN defines for a settled dependency. Where the engine holds a complete file set captured from that failing reviewer round, it MUST stage that set into each target under the reviewer's name, and the entry's staged-file flag MUST say whether it did. The engine MUST NOT give "revision" to a target in the first round or to the reviewer in any round. A body task that declares a session MUST resume that session in every round.

Each round of each body task MUST produce one session row named after the task followed by "[round-N]", written as that round settles and carrying that round's status, output, note, and spend. A first round that dispatched no body task MUST produce no round rows. Once the loop ends, each body task MUST produce one row under its own name carrying its last round's status, output, note, blocked reason, and transport cause, and the sum of every round's spend. A round row MUST NOT affect the verdict or halt the run; the rows under the tasks' own names do, exactly as RFC-0002:C-PLAYBOOK-LANE states for any task. A dependent of any body task MUST read the last round's output and captured files.

Every round settles its work as any task does. A passing round's workspace changes MUST be committed to the run's git memory and a failing round's MUST be discarded, as RFC-0002:C-PLAYBOOK-LANE requires, so a later round runs on top of every earlier passing round. A round's captured file set MUST replace the set its task published in an earlier round under the rules of RFC-0002:C-TASK-FILES and RFC-0002:C-SETTLED-JOIN.

The admitted-plan event MUST carry a reviewer's targets under "revise", as a string when it names one and as a list in the order it names them when it names several, and its bound under "max_rounds", and MUST carry an empty string and zero for every other task, so a reader that predates chains reads every plan without one.

Since: v0.1.0

[RFC-0002:C-RUN-HISTORY] History of a launch series (Normative)

A launch series is the set of runs one standing launch started. A run started any other way belongs to no series. A series history is a read-only view of what earlier runs in the series recorded, and is separate from a schedule cursor, which carries a position rather than a log. A run contributes to it only through its record task's declared output, and no run can alter a record already written. Nothing here restricts what a task reads from outside the engine, such as a repository or a published artifact.

A playbook MAY name one task as its history record. The engine MUST reject a mapped record task. Each run records that task's terminal status under the vocabulary RFC-0001:C-WIRE defines for the task-result event, and its declared JSON output where it passed or null where it did not; a revise target or reviewer records the result under its own name. Where the record task never settled, because the run stopped early, was stopped by an operator, or ended on a ceiling before dispatching it, the run records a null status and a null output. The record task MAY be an epilogue task, so that a run whose main graph failed still records what it found. A playbook that names no record task records only its outcome.

A task MAY declare that it reads history, with a depth. The engine MUST reject a depth outside 1 to 30, and MUST reject a record task or a history depth in any workflow type other than "playbook". The engine MUST give a task that declares a depth the reserved input "history": an object carrying under "records" a list of at most depth records, ordered by the end time of the run each describes, oldest first, and under "dropped" the number of records within this task's depth that the size bound below removed. A run that belongs to no series, or whose series has no prior terminal run, MUST receive an empty list and zero. The engine MUST reject a plan that names a dependency "history", as RFC-0002:C-SETTLED-JOIN requires for every reserved input.

A run of a series is terminal once the orchestrator has recorded that it ended, whether or not the run itself wrote a shutdown event. Each record MUST carry the prior run's identifier, its start and end times, its shutdown outcome under the vocabulary RFC-0001:C-WIRE defines for the shutdown event or null where the run wrote none, its verdict or null where it has none, the pack revision it ran, a controller-owned link to it, and the record task's name, status, and output as recorded above. The engine and the orchestrator MUST NOT add any other field to a record.

History MUST include every terminal run of the series: a run that failed, was stopped by an operator, ended on a cost or wall-clock ceiling, or ended without a shutdown event is part of the history as much as one that passed. A run that has not reached a terminal state MUST NOT appear.

The orchestrator that starts a run in a series MUST supply the series' most recent terminal runs by end time, up to the engine's ceiling of 30, and the engine MUST give each task only the most recent records its depth allows. The total encoded size of the history one task receives MUST be bounded by an operator-configured limit that is discoverable before a run starts. Where the records within a task's depth exceed it, the engine MUST drop whole records from the oldest end and MUST NOT truncate a record.

History MUST reach an agent prompt only inside a region the prompt marks as external input, under the same obligation RFC-0002:C-PLAYBOOK-PARAMS states for parameter values: an earlier run's record may carry text an earlier agent wrote. A resumed run MUST give each task the same history the interrupted run gave it.

The admitted-plan event MUST carry the playbook's record task under "history_record", or an empty string where it names none, and each task's depth under "history_depth", zero where it declares none.

Since: v0.1.0

[RFC-0002:C-PACK-BASE] Base packs (Normative)

A pack's files are the regular files under its root, excluding any path with a segment named state, .git, or workspace. Files and non-regular entries under an excluded segment are ignored, and a save, registration, or import MUST report the paths it ignored. A pack file's path MUST be relative to the root, use / separators, be valid UTF-8, and contain no empty, ., or .. segment and no newline. The controller MUST refuse to save, register, or import a pack that holds a symbolic link or another non-regular file outside an excluded segment. No file mode is part of a pack: every pack file MUST be delivered to a run and published with mode 0755.

A pack's digest MUST be the string tree1: followed by the lowercase hex SHA-256 of its tree summary: one line per pack file, in byte-wise order of path, each line the lowercase hex SHA-256 of the file's bytes, two spaces, the path, and a newline. The digest MUST NOT depend on file mode, timestamps, or how the pack is archived, compressed, stored, or delivered. A pack's revision is its digest, and every pin, comparison, and approval of a pack MUST use it.

A launch MUST record the digest of the pack it runs, and dispatch MUST deliver the stored tree under that digest and no other. Per-run inputs that are not pack content, such as steering, MUST be delivered beside the pack's tree and MUST NOT be written into it. A standing launch pinned to a pack MUST record that pack's digest. A standing launch that runs a draft's latest saved version that compiles MUST NOT record a digest; each launch it creates MUST record the digest of the draft's latest saved version that compiles at the time of that firing.

A draft version MAY name a base: a pack identified by its digest. A base digest is available to a subject when at least one registered or imported pack that has not been deleted and that the subject may read under RFC-0003:C-READ-SCOPING has that digest as its current revision or as one of its prior revisions. A pack registered from a source repository or imported MUST NOT name a base, and a base MUST NOT be expressed in the pack's manifest. The controller MUST refuse to save a draft version whose base digest is not available to the caller, and MUST decide availability before any other check that depends on the base. When the base is not available the controller MUST return only one refusal, which names the base digest as given and states that the base is not available, so that a caller cannot tell a missing base from one it may not read; no refusal that depends on the base's content may be returned instead. The one exception is a replaced digest, which MUST be refused as the migration paragraph of this clause requires.

Saving a draft version that names a base MUST flatten it: the base's files with the overlay's files placed over them by path. An overlay file at a path that is a base file's path MUST replace that file whole, every base file at a path the overlay does not hold MUST be kept unchanged, and contents MUST NOT be merged, so an overlay cannot remove a base file. The controller MUST refuse to save, register, or import a pack, and to save a draft version, in which one file's path followed by / is a prefix of another file's path, across the base and the overlay when there is a base. An overlay MAY be empty or MAY equal part of the base; the flattened digest then equals the base's digest. The flattened tree is the pack: its digest is the version's digest, and compilation, exposure extraction, approval, launch, and delivery MUST use the flattened tree and no other.

A draft version saved on a base MUST record as provenance the base digest, the digest of the overlay's files computed as above, every overlay path, and which of those replaced a base path, and MUST return them with the version. A pack registered from that draft version MUST carry and return the same provenance. Provenance digests MUST NOT keep any stored tree. For each registered or imported pack that the requesting subject may read and that holds a version's base digest as a prior revision, the controller MUST report, with the version, that pack and its current revision digest. A pack the subject may not read MUST NOT be reported or counted.

Saving on a base copies the base's files into the pack. A subject that may read the saved pack reads those files, and reading, launching, sharing, or approving the saved pack MUST NOT require any action on the base. The availability check at save is the only authorization applied to the base's content, and a later deletion of the base or loss of access to it MUST NOT affect packs already saved on it.

The controller MUST keep a pack's stored tree, unchanged under its digest, while any draft version, registered or imported pack, launch, or standing launch records that digest. A registered or imported pack records its current revision and every prior revision.

The controller MUST declare a delivery budget, measured as the size in bytes of the gzipped tar the controller delivers as a run's pack ConfigMap, and MUST make the value retrievable. A launch delivered by image volume (RFC-0002:C-PACK-PUBLISH) is not checked against it at dispatch. The controller MUST refuse to save, register (including by draft publish), or import a pack whose tree exceeds the delivery budget in force at that time, and MUST refuse to dispatch a launch whose delivered tree exceeds the budget in force at dispatch, with a refusal that names the budget and the size. Limits on the file count and per-file size of a draft MUST apply to its overlay, not to the flattened tree.

A digest recorded before this clause took effect MUST be replaced by the digest of the same stored tree, computed after applying this clause's definition of a pack's files, and every pin, approval, base reference, and run record holding it MUST be rewritten to the replacement. Several replaced digests MAY map to one replacement. A stored tree that cannot be converted, because it holds a non-regular file, an invalid path, or a path collision outside an excluded segment, MUST stay recorded under its old digest as unconvertible by every resource that holds it, and every request that would compile, deliver, publish, launch, or use it as a base MUST be refused with a refusal that names the reason. The controller MUST refuse a request that supplies a replaced digest: to a caller who may read some resource that recorded it, with a refusal that names it as superseded and gives its replacement; to any other caller, exactly as for an unknown digest, and as a base with the unavailable-base refusal.

Approval of a pack saved on a base is governed by RFC-0003:C-APPROVALS, and publishing packs to an OCI registry by RFC-0002:C-PACK-PUBLISH.

Since: v0.1.0

[RFC-0002:C-PACK-PUBLISH] Publishing packs to an OCI registry (Normative)

Publishing packs to an OCI registry (OCI publication) is optional. With no pack registry configured, the controller MUST NOT attempt it, and saving, registering, importing, approving, launching, and dispatching MUST NOT require a pack registry to be configured or reachable.

A dispatch target is registry-trusted when it is owned by the platform administrator team or a platform administrator has marked it so. The mark MUST NOT be set or cleared by any other subject, including through update on the target, and every change to it MUST be audited. Pull access to the pack registry MUST be limited to the controller and to registry-trusted targets, and MUST NOT be granted to any user or team principal. A target that is not registry-trusted MUST receive packs only through controller-built delivery, never by registry pull. The pack registry is not a read path for people: reading a pack is decided under RFC-0003:C-READ-SCOPING through the controller.

When a pack registry is configured, the controller MUST publish every registered pack revision, every imported pack revision, and every approved draft graduation, including those that existed before the registry was configured. OCI publication MUST NOT be refused for size. The artifact MUST use the OCI empty config and layers of media type application/vnd.oci.image.layer.v1.tar+gzip whose extraction in order, a later file replacing an earlier one at the same path, reproduces exactly the pack's files. A pack saved on a base MUST have two layers, the base's files and then the overlay's files, while the base's stored tree is held; when it is no longer held, the pack MUST be published as one layer holding its tree, and its publication state MUST record that the base layer is not shared. A pack with no base has one layer. A layer's bytes MUST be determined by the files it holds and the controller's layer encoding version alone, so that every pack published on one base under one encoding version carries an identical first layer. The layer layout applies only to the first publication of a digest: when the tag for a pack's digest already exists in the repository, the controller MUST NOT push a new manifest and MUST record the existing manifest digest as the pack's published manifest. The manifest MUST carry the annotation dev.crucible.pack.digest with the pack's digest as RFC-0002:C-PACK-BASE spells it, and dev.crucible.pack.kind with the value playbook.

Every published pack MUST be published to one repository under the tag tree1- followed by the hex part of its digest. The controller MUST NOT move that tag to another manifest, and MUST keep it while any draft version, registered pack, launch, or standing launch records the digest.

Each registered pack revision, imported pack revision, and approved draft graduation MUST carry a publication state of unpublished, pending, published with the manifest digest, or failed with the last error, readable by any subject that may read the pack. A failed OCI publication MUST NOT undo or block the registration, import, or approval it follows. A network error or a registry response of 429 or 5xx is transient; any other registry error is permanent. While a transient failure is retried with backoff, the state MUST be pending with the last error. The controller MUST NOT retry a permanent failure until a subject allowed the update action under RFC-0003:C-DECISION on the registered pack, the import, or the graduated draft requests a retry. Changing or removing the pack registry MUST reset every affected publication state to unpublished.

A launch MAY be delivered by image volume only when its target is registry-trusted and can mount OCI artifacts and the pack's publication state is published. The mount MUST reference the recorded manifest digest. Every other launch MUST be delivered as the pack ConfigMap and is subject to the delivery budget of RFC-0002:C-PACK-BASE.

Since: v0.1.0

[RFC-0002:C-HUMAN-DECISION] Human-decided routes (Normative)

A human-decided route takes its answers from a person allowed to approve the run, and holds the route's dependents until one answers or the request expires. It is how a playbook keeps an expensive or irreversible branch, such as a compute job an agent turn proposed, from starting before someone has seen what it would act on. The questions, labels, and when coverage rules of RFC-0001:C-ROUTE apply to it unchanged.

A human-decided route MAY ask two kinds of question no other decider asks. A multiple choice takes one or more of its declared labels rather than one. A pick names a list field of one of the route's dependencies as its source, and takes one value, or with multiple one or more, from the strings that field holds when the request opens. A pick has no labels, so no when may name it. A route decided by a model or a dependency's output MUST NOT ask either kind, and a plan that does MUST be rejected before any task is dispatched. A pick whose source is not a dependency of the route, or whose producer declares emits without the field or types it as anything but a list of strings, MUST be rejected the same way. When the request opens, a pick's source output MUST hold a non-empty list of strings; otherwise the route MUST fail naming the source, before the request opens. A human-decided route MUST NOT name a question "decision", the key its output records the decision under.

A human-decided route MUST declare needs = "human", verified before dispatch under RFC-0002:C-PLAYBOOK-CAPS. A run whose orchestrator cannot take an answer from a person does not have that capability.

When a human-decided route is dispatched, the engine MUST open a decision request with the orchestrator. The request MUST carry the run, the route's name, its questions with their declared labels and, for each pick, the values it offers, the evidence and its digest under RFC-0002:C-DECISION-EVIDENCE, and the time it expires. A request is identified by its run and route name. The orchestrator MUST hold at most one request per pair, and an open for a pair that already has one MUST return that request, with any answer recorded on it, rather than open another or reset its expiry. The engine MUST obtain the answer through requests it initiates; the orchestrator MUST NOT be required to open a connection to the run. Failure to reach the orchestrator is a transport failure and retries as one, and MUST NOT answer or expire the request.

While a request is open, the route runs no agent turn and spends nothing. Tasks that do not depend on the route MUST continue to dispatch as the graph allows. The wait counts against the run's wall-clock ceiling as any attempt does under RFC-0002:C-PLAYBOOK-BUDGET.

A human-decided route MAY declare a timeout. A declared timeout MUST be positive, and one exceeding the run's wall-clock ceiling MUST be rejected before any task is dispatched; with none declared, the ceiling is the timeout. Expiry MUST be counted from the time the request was first opened, and neither a restart nor a resume MAY extend it. When a request expires unanswered, the engine MUST record every question as "uncertain" and settle the route passing. This replaces, for a human-decided route only, the measured failure RFC-0002:C-PLAYBOOK-BUDGET prescribes when an attempt reaches its own limit: an unanswered request is an outcome the author routes, and the coverage rule of RFC-0001:C-ROUTE requires that they route it. When the run's wall-clock ceiling falls first, the route settles as that clause prescribes for the ceiling.

An answer MUST give every question exactly one of its options, or for a multiple choice or a multiple pick one or more distinct options. A choice's options are its declared labels, and a pick's are the values its request offers. "uncertain" MUST NOT be accepted as an answer. An answer MAY carry a note of bounded size, and the bound MUST be operator-configured and discoverable before a run starts. The orchestrator MUST accept an answer only from a subject allowed approve on the run under RFC-0003:C-DECISION, only while the request is open, and only when it names the evidence digest the request carries. It MUST refuse any other answer with a refusal that names the reason. The first accepted answer is final. A later answer that repeats it exactly from the same subject MUST be acknowledged without effect, and any other later answer MUST be refused. Stopping the run while a request is open MUST withdraw the request, and an expired or withdrawn request MUST refuse every answer.

The route's output MUST carry, for each choice, the resolved label with probability 1, or "uncertain" with an empty distribution and confidence 0, in the shape RFC-0001:C-ROUTE gives an output-decided route. For a multiple choice it MUST also carry every chosen label in the order given, each with probability 1, and the resolved label is the first of them; a when on it runs its task when any label it lists was chosen. For each pick, the output MUST carry the chosen values as a list of strings under the question's id, in the order given, and an expired request gives an empty list. A task MAY map over that list under RFC-0001:C-WORKFLOW. It MUST also carry the decision: the deciding user principal, the time, the evidence digest, and the note or null, or, for an expired request, a null principal and the outcome "expired". This output is the record RFC-0002:C-PLAYBOOK-RESUME folds, so a resumed run never asks again for a route that settled. A resumed run whose human-decided route has no folded result MUST open its request again, and MUST settle on any answer recorded while the run was down.

A note is text from outside the pack. It MUST reach an agent prompt only inside a region the prompt marks as external input, under the obligation RFC-0002:C-PLAYBOOK-PARAMS states for parameter values.

Since: v0.1.0

[RFC-0002:C-DECISION-EVIDENCE] Decision evidence and notification (Normative)

The evidence of a decision request is what the approver decides on. It MUST consist of exactly: each of the route's dependencies with its terminal status, its declared JSON output where it passed, and its declared files staged under RFC-0002:C-TASK-FILES, as the route's join would give them to it as inputs; the run's spend, elapsed time, and both ceilings at the time the request opened; for each task whose when names a question of the route, its name, kind, needs, and the labels that dispatch it, so the approver sees what each answer starts; the rendered review, where the route declares one; and for each pick question, the values it offers. Nothing a task did not declare MAY appear in it: no prompt, stdout, undeclared workspace file, or credential.

A human-decided route MAY declare a review: a template file in the pack, rendered into a CommonMark document shown above the rest of the evidence. The template MUST be able to read every part of the evidence above and nothing else. The renderer MUST escape every value the template inserts, and the controller MUST display the rendered review without executing any raw HTML in it.

The total encoded size of the evidence MUST be bounded, and the bound MUST be operator-configured and discoverable before a run starts. Evidence exceeding it MUST fail the route before the request opens, naming the bound, and MUST NOT be truncated.

The evidence MUST have a digest computed over its canonical encoding, rendered review and file contents included. The digest MUST be recorded on the request and on the decision. The controller MUST present the approver the evidence that digest names, and an answer is bound to the evidence it names: RFC-0002:C-HUMAN-DECISION refuses an answer naming any other digest.

The controller MUST present a request's evidence to an approver on one page together with the controls that answer it, with each question's options as the only choices, and with whether a question takes more than one. Declared JSON outputs MUST be shown as structured values that keep their JSON types. Declared files MUST be shown inline by media type at least for PNG, JPEG, GIF, and SVG images, Markdown, CSV as a table, JSON as a structured value, and HTML as an interactive document; a file of any other type, and a file whose inline presentation fails, MUST be offered for download. An HTML file is how a pack ships a visualization: a self-contained document whose scripts run when it is shown.

Every file in the evidence is untrusted: an agent may have written it. The controller MUST show SVG and HTML evidence in a context that cannot run script with the controller's origin, read its cookies or storage, call its API, navigate the page that embeds it, or make any network request. A visualization therefore has to carry its scripts and data inline.

The controller MUST list the open decision requests a subject may answer, and MUST publish an event on its live event stream when a request opens and when it closes, delivered only to subjects that may read the run. The web UI, while open in a browser that granted it notification permission, MUST raise a system notification for each request the signed-in subject may answer, linking to the request's evidence page, and MUST NOT raise one for a request the subject cannot answer. Delivery to a browser with no open session of the web UI is outside this clause.

Since: v0.1.0


Changelog

v0.1.0 (2026-08-21)

Initial draft

Added

  • C-PACK-PUBLISH: registered, imported, and approved packs are published to a configured OCI registry under immutable tags; saves and launches never depend on the registry
  • C-PACK-BASE: a pack's digest is SHA-256 over its canonical tree; a draft may name a base, flattened at save into one tree with one digest, base and overlay digests kept as provenance; an unavailable base refused at save; the delivery budget checked at save and dispatch
  • C-PACK-BASE: a pack's digest is a tree hash (tree1:); a draft may name a base, flattened at save into one tree with one digest and recorded provenance; an unavailable base is refused at save; the delivery budget is checked at save, registration, import, and dispatch; old digests are rewritten
  • C-PACK-PUBLISH: optional OCI publication of registered, imported, and approved packs, readable only by the controller and dispatch targets, under digest-derived tags with a publication state
  • C-HUMAN-DECISION, C-DECISION-EVIDENCE: a human-decided route opens one decision request per run and route, polled by the run, answered against an evidence digest, recorded as uncertain on expiry; the controller shows the evidence with sandboxed visualizations and notifies approvers

Changed

  • a settled join forwards a dependency that settled not taken
  • converting external text to a plain string, and a supplied value carrying a marker, are compile errors
  • the run's wall-clock ceiling is the default and maximum per-attempt deadline; a timed-out attempt fails measured and is not retried
  • C-TASK-FILES: a declared file may carry a schema, validated from the captured copy when the task passes
  • C-TASK-FILES: an absent or mismatched file is a repair note for an agent task that declares repair
  • C-PLAYBOOK-BUDGET admits each fan-out lane against settled and in-flight spend under a bounded width, and the cost ceiling blocks only tasks that can spend, so commands after it still run; C-SETTLED-JOIN and C-PLAYBOOK-LANE follow
  • C-PLAYBOOK-PARAMS: a supplied dotted name is a launch setting (., *., repo.ref), checked before dispatch and written onto the compiled tasks; a declared parameter name cannot contain a dot
  • C-PLAYBOOK-PARAMS: a string parameter may carry a multiline flag for launch forms; it does not change binding
  • C-SETTLED-JOIN: a failing task's own note, from a self-declared failure or a command's nonzero exit, is the note the engine records
  • C-SETTLED-JOIN: an all-join task gets no entry for an optional dependency that settled not taken (RFC-0001:C-ROUTE)
  • C-PLAYBOOK-RESUME: a resume reads the manifest variant the interrupted run read
  • C-SETTLED-JOIN: a failing evaluation's own note is recorded with its threshold and log handles, and a declared status fails or skips a task whatever its exit status or grade but never passes one
  • C-PLAYBOOK-LANE: only a command or evaluate task may declare early completion; any other task's declaration is refused on its session row and the run continues
  • C-TASK-FILES: a mapped instance's key may hold a separator or a dot; its captured and staged directory escapes %, /, , NUL, and a leading . as %25, %2F, %5C, %00, and %2E, and the key stays raw in names, rows, inputs, and resume
  • C-SETTLED-JOIN: an instance is staged under its own name with its key escaped

Fixed

  • C-PLAYBOOK-LANE no longer claims git memory survives a resume; C-PLAYBOOK-RESUME governs the rebuilt workspace

RFC-0003: Teams, ownership, and policy-based authorization

Version: 0.1.0 | Status: normative | Phase: spec Owners: @Will Eaton


1. Summary

[RFC-0003:C-CONTEXT] Context and goals (Informative)

The controller authorizes today through three unrelated mechanisms: a global two-tier role ladder configured by login lists, principal ownership on secrets and personal dispatch targets, and a source-forge approver allowlist. Ownership is recorded on standing launches but never consulted on edit or delete, a team cannot own a schedule, ownership alone never suffices without a global role, every read surface is open to any authenticated caller, and an agent inside a run acts with its launcher's full authority. This RFC defines one authorization contract: an open principal registry that adds controller-managed teams (composed of users, groups, rules, and other teams) and run principals; an owner on every governed resource with per-resource shares and a parent relation; a single deny-by-default decision bound to every route; and a policy set with a published schema that expresses roles, ownership rules, shares, expiry, dispatch admission, and approver sets as data. The decisions that define the secrets registry and brought clusters will be amended to defer to it on who may act. It does not prescribe a policy engine, a storage layout, an evaluation architecture, or a user interface framework; those choices belong in decision records that reference this RFC.

Since: v0.1.0


2. Specification

[RFC-0003:C-PRINCIPALS] Principals (Normative)

A principal is <scheme>:<id>. Schemes are registered: this RFC registers user, group, team, and run, and a later amendment MAY register more. The policy schema of RFC-0003:C-POLICY MUST be versioned over the registered schemes, and a principal whose scheme is not registered MUST deny. user:<login> and team:<slug> MUST be stored and compared lowercased and trimmed. group:<path> MUST be stored and compared exactly as the identity layer normalizes it, applied identically at write and at comparison, so that distinct identity-provider groups are never merged by the controller. A team slug MUST match [a-z0-9][a-z0-9-]{1,62} and MUST be unique per controller. User and group principals MUST derive from a controller-validated identity (the session, token, or key the controller itself verified); a caller MUST NOT be able to assert a group by supplying it in a request. Team principals MUST derive from the controller's own team membership records; run principals from RFC-0003:C-RUN-PRINCIPAL. Every request has a subject, the principal set the decision evaluates, and an actor, the principal that caused the request: the authenticated user for an interactive or API-key call, the submitter when a portal records one, otherwise the owner principal of the launch or standing launch that fired. Subject and actor coincide for interactive and API-key calls unless the caller acts as a team under RFC-0003:C-ACT-AS, and differ for firings, portal submissions, and runs. The subject set is the subject's own principal, every group principal the credential proves, and every team principal whose membership those principals satisfy under RFC-0003:C-TEAMS. The group refresh window is the configured interval at which the identity layer re-reads a credential's groups from the identity provider; a downgraded session is one whose refresh the provider refused. A credential that cannot prove groups (a static token, a cluster token, a downgraded session) MUST act as its user principal plus the team memberships whose derivation contains no group or rule member, including through nested teams, and MUST NOT act as any group-derived or rule-derived membership.

Since: v0.1.0

[RFC-0003:C-TEAMS] Teams (Normative)

A team is a controller-managed record with a unique slug, a display name, and a member set. Each member MUST be a user principal, a group principal, another team, or a membership rule, paired with a team role. Team roles are totally ordered member < maintainer < owner; an amendment adding a role MUST place it in this order. A membership rule is a predicate over attributes of a controller-validated identity (at least: email domain, and group path prefix); it MUST be evaluated only against attributes the identity layer verified, and a change to a rule MUST be audited like a change to a listed member. Team membership MUST form a directed acyclic graph and a membership that would close a cycle MUST be refused. Along a nesting path the conferred role is the minimum of the roles on that path; a user's role in a team is the maximum over all paths that reach them. Group-derived and rule-derived membership MUST reflect the identity provider within one group refresh window. A team is itself a governed resource whose owner principal is the team; a team is not transferable and transfer on a team MUST deny. On creation the request MUST list at least one member at the owner role that currently resolves to a user; absent an explicit list, the creator is listed at owner. Creating a team, changing its members, rules, or roles, renaming it, and deleting it MUST be authorized by the decision in RFC-0003:C-DECISION and audited as RFC-0003:C-AUDIT requires. A team MUST NOT be deleted while it owns any other governed resource; the refusal MUST name the blocking resources the caller may read and the count of any others. A team none of whose memberships currently resolve to any user MUST remain the owner of its resources and MUST be listed as unreachable in the team listing available to platform administrators. Platform administration MUST be a team, so that administrator membership can be held through a group and changed without a redeploy.

Since: v0.1.0

[RFC-0003:C-OWNERSHIP] Ownership of governed resources (Normative)

Every governed resource MUST carry exactly one owner principal that is a user or a team. The governed resources are: registered playbooks, playbook drafts, pack imports, autoresearch scopes, standing launches and their triggers (schedules, watches, webhooks, portals), one-shot launches, runs, secrets and their bindings, model providers and dispatch defaults scoped below the platform, dispatch targets including brought clusters, API keys, policy sets, teams, and every child resource named in RFC-0003:C-HIERARCHY. An API key's owner MUST be the user it acts as. For a root resource the owner MUST be chosen by the creator from the principals the request acts as, defaulting to the creator's user principal; a creator MUST NOT set an owner they do not act as, and acting as the intended owner is necessary and not sufficient: create on that owner is still decided by policy. For a child resource the owner is its parent's owner and is not chosen; create on the child is decided against the parent's owner and scope. The owner MUST NOT change except through the transfer action on a root resource. Ownership MUST be transferable only to a principal the transferring caller acts as or, for a platform administrator, to any user or team; a transfer that would collide with an identifier the new owner already holds under RFC-0003:C-NAMES MUST be refused; and every transfer MUST be audited. A resource whose owner is a team MUST remain usable when any individual member leaves; a resource whose owner is a user MUST remain visible to platform administrators so it can be transferred when the user departs.

Since: v0.1.0

[RFC-0003:C-ACTIONS] Action vocabulary (Normative)

Authorization MUST be expressed over a fixed, documented action vocabulary per resource type, using stable identifiers of the form <resource>:<verb>. Every resource type MUST define at least read, create, update, delete, transfer, and share; playbooks, drafts, and standing launches MUST additionally define launch; imports, scopes, and drafts (graduation) MUST define approve; secrets MUST define bind and rotate; dispatch targets MUST define dispatch; runs MUST define publish and approve, answering a decision request the run opened under RFC-0002:C-HUMAN-DECISION; drafts MUST define publish, registration into the registry without review, which a rule deriving from ownership alone MUST NOT grant; policy sets MUST define activate; teams MUST define manage-members. transfer on a child resource resolves to transfer on its root under RFC-0003:C-HIERARCHY. The vocabulary MUST be retrievable from the API by any authenticated user principal. Adding an action is a compatible change; renaming or removing one is a breaking change under the RFC lifecycle. A request naming an action or resource type outside the vocabulary MUST be refused.

Since: v0.1.0

[RFC-0003:C-DECISION] One authorization decision (Normative)

Every API operation MUST be authorized by one decision that takes: the subject principal set and the team role held under each team principal in it; the actor; one action from RFC-0003:C-ACTIONS; the resource type, the resource's owner principal, its ancestor chain under RFC-0003:C-HIERARCHY, and where the action is dispatch the resolved target (or, for create and listing, the resource type with the intended owner and parent); the active policy set; the shares on the resource and its ancestors; and the current time. It MUST answer allow or deny with a structured reason naming the rule that decided or the absence of one. The decision MUST be deny by default: an empty subject set, an action outside the vocabulary, or an unresolvable resource MUST deny. The same decision MUST serve the browser, the API-key path, the MCP surface, run principals, and non-interactive re-checks, so that what a preview shows, what a launch is refused with, and what a firing standing launch may do agree. A standing launch, watch, webhook, or portal MUST fire with its owner as subject and that owner's memberships resolved at firing time, never from a stored snapshot; when the owner is a team, the firing's subject is that team principal at the maintainer role. The actor of a firing is the submitter when one exists, otherwise the owner. A firing whose owner cannot be resolved (a deleted user, a team with no resolvable member) or whose decision denies MUST be refused, handled under RFC-0001:C-RECURRING-POLICY, and audited. An update that changes what a standing launch fires (its playbook, inputs, resolved target, or bindings) MUST additionally be decided as a launch by the updating subject, who MUST hold launch on the playbook, dispatch on the resolved target, and bind on each bound secret, or the update MUST be refused. Roles, ownership, team membership, shares, and approver sets MUST all be inputs to this one decision; an endpoint MUST NOT consult an independent allowlist, environment list, or role check instead of it. Disclosure of a denial is governed by RFC-0003:C-READ-SCOPING.

Since: v0.1.0

[RFC-0003:C-ENFORCEMENT] Enforcement at the boundary (Normative)

Every served route MUST be bound to exactly one action and one resource resolution, and a request to a route with no binding MUST be denied for every caller. The route-to-action bindings MUST be retrievable so that conformance can be checked against the served route table. A response MUST NOT reflect any effect of a request the decision denied. A list MAY be narrowed only by applying the same decision per item. Machine surfaces that authenticate by a pod-bound credential rather than a principal (a pod token's evidence uploads under RFC-0004:C-RUN-EVIDENCE, metrics) and the webhook delivery route, which authenticates by a per-webhook secret under RFC-0003:C-WEBHOOK-DELIVERY, are outside this contract and MUST NOT be granted any action from RFC-0003:C-ACTIONS. Routes that manage webhooks or read their deliveries are served routes under this clause.

Since: v0.1.0

[RFC-0003:C-POLICY] Policy as stored, validated data (Normative)

The rules the decision evaluates MUST be a policy set with a published schema over the principal, action, and resource vocabularies of this RFC. A policy set MUST be validated against that schema before it becomes active, and an invalid set MUST be refused with the failing rule named. Validation MUST also refuse a set that grants manage-members or transfer on a team to a role below owner, and a set under which no user or team principal would hold activate. The active policy set MUST be identified by a content digest, and the policy set is a governed resource read through the read action, which the default policy set MUST grant to all authenticated user principals. The controller MUST ship a default policy set that grants: a user owner every action on the resources they own; on a team-owned resource, the team's member role read and launch, the maintainer role additionally create, update, bind, and rotate, and the owner role every action including transfer, share, delete, and manage-members; every authenticated user principal dispatch on a dispatch target owned by the platform administrator team, matching the open default of the cluster policy it replaces; and the platform administrator team every action on every resource. Activating a policy set is the activate action, decided by the policy in force at that moment, and MUST be audited with the prior and new digests.

Since: v0.1.0

[RFC-0003:C-APPROVALS] Approvers as policy (Normative)

Who may approve a pack import, a scope, a draft graduation, or a run's decision request MUST be decided by the approve action through RFC-0003:C-DECISION, with approver sets expressed as team roles or policy rules rather than a separate allowlist. An approval recorded from an external system (a source-forge review) MUST be attributed to a controller identity through a verified account link: an association between the external login and a controller user that the user established while authenticated to both. An external login with no verified link MUST NOT approve. Any existing approver allowlist or forge-association fallback MUST be honoured only through an explicit policy rule that names it, so that removing the rule removes the path.

An approval of a draft graduation MUST record the draft and the digest it approves (RFC-0002:C-PACK-BASE) and MUST apply to every version of that draft whose digest equals the approved digest, and to no other. An approval recorded for any other resource, a base included, MUST NOT be treated as an approval of it, even when the digests are equal. The approval request for a pack saved on a base MUST show the base digest, each registered or imported pack the approver may read under RFC-0003:C-READ-SCOPING that has that digest as its current revision, every overlay path, and which of those paths replaced a base path. A scope approval binds the exposure extracted from the pack and is not changed by this paragraph.

The default policy set MUST grant approve on a run to the owner of the launch that produced it, and to the members holding at least the maintainer role where that owner is a team; a policy MAY narrow or widen the set. approve on a run MUST NOT be granted to any run principal, by the default policy set or by any rule: an approval the gated run could give itself guards nothing. An answer to a decision request MUST be bound to the evidence digest it names under RFC-0002:C-DECISION-EVIDENCE, and to no other evidence.

Since: v0.1.0

[RFC-0003:C-READ-SCOPING] Read scoping (Normative)

Reading a governed resource is the read action and MUST be authorized like any other. A list endpoint MUST return only the items the caller may read and MUST NOT reveal whether items were withheld. When a caller lacks read on a resource, every action on that resource MUST return the same response as for a resource that does not exist; the structured denial of RFC-0003:C-DECISION is returned only when the caller may read the resource. A child resource is read under its parent's scope as RFC-0003:C-HIERARCHY defines, and a resource's shares are read with it. Team memberships and the active policy set are governed resources read under the same rule.

Since: v0.1.0

[RFC-0003:C-CREDENTIAL-PARITY] Credential parity (Normative)

The decision MUST produce the same answer for the same user regardless of which credential proves them, provided the credential proves the same groups. An API key MUST act with the owner's group memberships as refreshed within the group refresh window that governs sessions; a key whose owner's groups cannot be refreshed within that window MUST act as the user principal plus directly listed team memberships until they can. Revoking a team membership or a group MUST take effect for every credential of the affected user within one group refresh window. The identity probe MUST state whether the presenting credential proves groups.

Since: v0.1.0

[RFC-0003:C-AUDIT] Audit (Normative)

The controller MUST record an audit event for: team creation, membership, rule, and role changes, and deletion; ownership assignment at creation and every transfer; share grant, change, and revocation; activation of a policy set; issuance of a run credential; every accepted answer to a decision request, with the evidence digest it names; and every denied mutation. Each record MUST carry the actor, the subject when it differs, the credential path that proved the request, the action, the resource type and identifier, the rule that decided, the prior and resulting state where the operation changed one, and the time. An operation whose audit record cannot be persisted MUST fail. Audit records MUST be retrievable by the owner of the affected resource, by the affected team's owners, and by platform administrators, under the read scoping of the resource they concern. This extends RFC-0001:C-AUDIT from administrator policy changes to authorization changes generally.

Since: v0.1.0

[RFC-0003:C-COMPATIBILITY] Compatibility and migration (Normative)

Adoption MUST NOT grant any principal an action the prior mechanisms denied, except grants the active policy set makes explicitly to user owners, team roles, and shares, and every such grant MUST be attributable to a policy rule. Existing governed resources MUST be backfilled with an owner in this precedence: the recorded owner snapshot if present, else the recorded creator, else the platform administrator team. A resource owned by a group:<path> principal MUST be migrated to a team whose sole member is that group at the owner role, created for the purpose when none exists; the team's slug is the group path's last segment lowercased with every character outside [a-z0-9-] replaced by -, suffixed with -2, -3, and so on when taken; the mapping MUST be audited. Existing administrator and operator login and group lists MUST be migrated into a platform administrator team and a platform operator team with policy rules granting the actions those roles gate today, as the route bindings of RFC-0003:C-ENFORCEMENT record them; the existing cluster policy MUST be migrated into dispatch rules under RFC-0003:C-CONDITIONS. A fresh install, or one whose platform administrator team has no membership that resolves to a user, MUST seed that team from configuration at startup and MUST NOT offer any request path that creates the first administrator. During migration a resource governed by both an existing guard and the new decision MUST be allowed only when both allow. The existing principal spellings user:<login> and group:<path> MUST remain valid and unchanged; team:<slug> and run:<id> are additive. The decisions that today define secret ownership and brought-cluster ownership will be amended to defer to this RFC on who may act; their custody, delivery, and probing content is untouched.

Since: v0.1.0

[RFC-0003:C-TEAM-UX] Team-centric views (Normative)

The user interface MUST present governed resources filtered by an owner context the caller chooses among the principals they act as, with each team and the caller's own user principal as a context, and MUST include resources shared with that context. Choosing a team as the owner context MUST act as that team under RFC-0003:C-ACT-AS; choosing the caller's own user principal or showing every owner MUST NOT. The owner picker on every creation surface MUST offer only the principals the caller acts as. A refusal MUST be shown with the reason the decision returned, verbatim. The interface MUST show the signed-in user's team memberships and roles as the controller resolves them, including memberships held through a group, a rule, or a nested team, and MUST show a resource's shares to anyone who may read it.

Since: v0.1.0

[RFC-0003:C-RUN-PRINCIPAL] Run principal (Normative)

Every run MUST act under its own principal run:<run id>, proven by a credential issued at dispatch that proves only that run, and MUST NOT act under its launcher's or owner's credential. The run principal's subject set is the run principal alone; its actor is the owner principal of the launch that produced it. The default policy set MUST grant a run principal exactly: read on its launch, on itself, and on their children under RFC-0003:C-HIERARCHY; and publish on itself, where publish is recording an output the launch's declared exposure permits under RFC-0001:C-EXPOSURE. Task logs, artifacts, events, and spend are recorded through the pod evidence routes that RFC-0003:C-ENFORCEMENT places outside this contract and are not actions of the run principal. A decision request under RFC-0002:C-HUMAN-DECISION is opened and polled with the run's credential, and its answer is read under read on the run. The grants of the policy set and vocabulary to authenticated user principals do not extend to run principals. A run credential MUST expire when the run reaches a terminal state and MUST be refused after that regardless of expiry. A request under a run principal for any resource outside its launch MUST return the not-found response of RFC-0003:C-READ-SCOPING.

Since: v0.1.0

[RFC-0003:C-SHARING] Per-resource shares (Normative)

A subject holding share on a governed resource MAY grant another principal a share role on that one resource. A share names the resource, the grantee principal, a share role from a fixed vocabulary of at least viewer (read), launcher (read and launch), and editor (read, update, launch, bind), and optionally a not-after time under RFC-0003:C-CONDITIONS. A share role confers only the actions the resource type defines. A share MUST NOT confer transfer, delete, share, approve, activate, or manage-members, and a grant of a role containing any action the sharer does not hold on the resource MUST be refused rather than clipped. A share is a resource-scoped rule stored with the resource, outside the digested policy set of RFC-0003:C-POLICY; the shares on a resource are part of its read. A share MUST be revocable by any subject holding share on the resource or by a platform administrator, and MUST be audited on grant, change, and revocation. Transferring a resource MUST preserve its shares. A share on a parent applies to its children under RFC-0003:C-HIERARCHY; a share is otherwise never inherited.

Since: v0.1.0

[RFC-0003:C-HIERARCHY] Resource hierarchy (Normative)

A governed resource MAY have exactly one parent resource, itself governed. A child MUST have the same owner as its parent, MUST inherit its parent's read scope and shares, and MUST NOT be transferred separately from its root. The parent relation is: a draft version's parent is its draft; a trigger's parent is its standing launch; a webhook delivery's parent is its webhook; a run's parent is the launch or standing launch that produced it; a run's task logs, artifacts, events, and spend records are children of the run; a binding's parent is its secret. Every spend record MUST carry, in addition to its inherited owner, the owner principal of its launch at the time of the spend; transfer changes the inherited owner and MUST NOT rewrite that attribution, so that spend sums by who spent it. Actions on a child that the vocabulary does not define for its type MUST resolve to the corresponding action on the nearest ancestor that defines it.

Since: v0.1.0

[RFC-0003:C-CONDITIONS] Rule conditions (Normative)

A policy rule or share MAY carry a not-after time; the decision MUST evaluate it against the controller's clock at decision time, and an expired rule or share MUST NOT allow anything. Expiry MUST be visible wherever the rule or share is listed. The dispatch action on a dispatch target MUST be decided per target through policy rules, so that which principals may dispatch onto which cluster or brought target is a policy fact and not a separate configuration; absent a rule, a target admits only its owner as RFC-0003:C-POLICY grants, and a platform-owned target admits every authenticated user principal by the default policy set. A launch or firing whose subject may not dispatch onto its resolved target MUST be refused before any pod exists. The policy schema MUST admit both conditions from its first version; other conditions are later amendments.

Since: v0.1.0

[RFC-0003:C-NAMES] Names are scoped to the owner (Normative)

The human-readable identifier of a governed root resource (a playbook id, a draft id, a secret name, a provider id, a target name) MUST be unique within its owner, and two resources with different owners MAY share an identifier. Team slugs are unique per controller as RFC-0003:C-PRINCIPALS requires. The API MUST address a governed resource by owner principal and identifier, with the owner principal carried as one percent-encoded path segment in its <scheme>:<id> spelling. The API MUST accept an unqualified identifier only where it resolves to exactly one resource the caller may read, and MUST refuse an ambiguous one as a conflict naming the count of candidates. Every existing resource keeps its identifier through the migration of RFC-0003:C-COMPATIBILITY, since assigning owners cannot create a collision within one owner.

Since: v0.1.0

[RFC-0003:C-ACT-AS] Acting as a team (Normative)

A caller MAY act as a team it holds a membership in by naming that team on each request. While acting as team T, the request's subject MUST be the team principal T at the lesser of the caller's role in T and maintainer: the subject a standing launch owned by T fires as under RFC-0003:C-DECISION, never above the role the caller holds. The caller's user principal, group principals, and other team memberships MUST NOT be in that subject set. The actor remains the caller, and an audit record MUST carry T as the subject. A request naming a team the caller does not hold MUST be refused and MUST NOT be answered as the caller's own subject. Only a team principal may be acted as. Every decision, read scope, and entitlement for the request MUST be made for that subject, except that managing the caller's own API keys, sign-in credential, and interface preferences acts for the caller, since those belong to the person rather than to any team. The interface MUST show the team the caller is acting as, and MUST show a refusal to act as a team with its reason.

Since: v0.1.0

[RFC-0003:C-WEBHOOK-DELIVERY] Webhook delivery surface (Normative)

A webhook is a trigger under RFC-0003:C-HIERARCHY whose firing is requested by a sender outside the controller; a standing launch has at most one webhook. The delivery address MUST carry an opaque webhook identifier and MUST NOT carry the owner principal or the playbook identifier. The delivery surface MUST authenticate every delivery by the webhook's own secret, using one verifier the owner chose from this set: an HMAC-SHA256 of the body in a named header, given as sha256= followed by the lowercase hex digest; a shared token in the delivery path. Adding a verifier is a compatible change; removing one is a breaking change under the RFC lifecycle. The surface MUST NOT record or act on a delivery its webhook's verifier does not prove. The body size limit MUST be the same for every webhook and MUST be applied before the webhook is looked up. Every delivery the surface does not record, whatever the cause (an unknown or deleted webhook, a disabled webhook, failed verification, or a body over the limit), MUST receive the same status code, body, and headers apart from per-response values such as the date and a request id. A presented secret MUST be compared in constant time. A refusal applied before the webhook is looked up and independent of its identifier, such as load shedding, MAY differ from that response. A delivery MUST be durably recorded before it is acknowledged. The acknowledgement MUST be the same whatever the delivery later settles to and MUST NOT wait for filtering, parameter derivation, or launch; a verified delivery that cannot be recorded MUST receive a response the sender can retry, distinct from the acknowledgement. The sender is not a principal and MUST NOT be granted any action from RFC-0003:C-ACTIONS.

Since: v0.1.0

[RFC-0003:C-WEBHOOK-SETTLEMENT] Webhook settlement (Normative)

Recorded deliveries to one webhook MUST settle in the order they were recorded, each to exactly one outcome of launched, filtered, duplicate, throttled, or failed, with the reason, and a delivery MUST NOT launch after it settles to any outcome but launched. Settlement MUST apply these steps in order, stopping at the first that settles: parsing the body as JSON (failed); the filter (filtered, or failed if it cannot be evaluated); deriving the deduplication key (failed); a consumed key (duplicate); the rate bound (throttled); deriving parameters and deciding the firing (failed); launching (launched). A webhook MUST launch only by firing its standing launch, decided under RFC-0003:C-DECISION as that standing launch's firing; a webhook firing has no submitter, and a firing the decision denies MUST settle failed with the decision's reason. A webhook MUST fire the playbook revision its standing launch pins and MUST NOT fire a draft, and a launch it mints MUST record the delivery that produced it. A key is consumed only by a delivery that settles launched, and a later delivery to the same webhook carrying a consumed key MUST settle duplicate for as long as the webhook exists; removing a delivery record MUST NOT release its key. Every webhook MUST carry a finite maximum number of launches in any 60-minute window, set by its owner and no greater than a platform maximum. Every value a webhook derives from a delivery MUST reach a run only as a parameter value that did not originate inside the pack under RFC-0002:C-PLAYBOOK-PARAMS; a run principal MUST NOT hold read on a webhook or a webhook delivery, whatever RFC-0003:C-RUN-PRINCIPAL grants on its launch's children. A settled delivery's outcome and reason, and its recorded body and headers, MUST be retrievable under the webhook's read scope for at least a retention period the webhook declares, no longer than a platform maximum, and are removed with the webhook. The recorded headers and address MUST exclude any header value or path segment that carries the webhook's secret or a previous secret.

Since: v0.1.0

[RFC-0003:C-WEBHOOK-LIFECYCLE] Webhook secrets and lifecycle (Normative)

A webhook's secret MUST be generated by the controller with at least 128 bits of entropy and disclosed once, in the response to the create or replacement that generated it; it MUST NOT be readable after that. A webhook's verifier, and the header it reads, MUST be fixed at creation; a save that changes either MUST be refused. Replacing the secret is update on the webhook and applies to deliveries received after the replacement; the controller MAY accept the previous secret for a declared overlap no longer than a platform maximum. No audit record, delivery record, or retrievable state MUST carry a webhook secret, a hash of it, or any value from which it can be checked; for secret generation and replacement, the audited prior and resulting state is the fact of the change and any declared overlap. Creating a webhook, and changing its filter, parameter derivation, or rate bound, MUST additionally be decided as an update that changes what its standing launch fires under RFC-0003:C-DECISION. A save whose parameter derivation names a parameter the standing launch's playbook does not declare, or whose expressions do not compile, MUST be refused. A webhook is enabled or disabled; enabling and disabling are update on the webhook. A disabled webhook, and a webhook whose standing launch is disabled, MUST NOT record deliveries. A recorded delivery that has not settled when its webhook is disabled or deleted MUST settle failed with that reason. The controller MAY disable a webhook after consecutive failed firings, counting only failures that do not derive from delivery content, and MUST audit that disabling with an actor that names the controller and no principal. Creating, enabling, disabling, and deleting a webhook, and replacing its secret, MUST be audited under RFC-0003:C-AUDIT.

Since: v0.1.0


Changelog

v0.1.0 (2026-09-13)

Initial draft

Added

  • C-ACT-AS, acting as a team narrows the subject to that team principal
  • C-WEBHOOK-DELIVERY, C-WEBHOOK-SETTLEMENT, C-WEBHOOK-LIFECYCLE, webhook triggers
  • C-ACTIONS: runs define approve, answering a decision request; C-APPROVALS grants it to the launch owner by default and never to a run principal

Changed

  • C-PRINCIPALS and C-TEAM-UX defer to C-ACT-AS
  • C-OWNERSHIP, C-HIERARCHY, C-DECISION, and C-ENFORCEMENT name webhooks and their deliveries
  • C-APPROVALS: a draft graduation binds the pack's digest, which for a pack saved on a base covers the flattened tree

RFC-0004: Linked controller/engine boundary

Version: 0.1.0 | Status: draft | Phase: spec Owners: @Will Eaton


1. Summary

[RFC-0004:C-SCOPE] Scope (Informative)

This RFC governs how the controller obtains work from the engine: rendered Kubernetes objects for turn and run pods, compiled workflows and their parameter schemas, run explainability documents, and the launching of an engine process for an agent turn or a local run. It applies to every deployment shape (pod dispatch, the local executor, the registry) and to both directions of version drift between a controller build and the engine it is paired with.

It does not govern what runs inside a pod once launched (the wrapper script, the loop, the judge), nor the human API the controller serves. Those keep their own contracts (RFC-0001, RFC-0002).

The motivating failure: the controller began sending a flag for a scenario's git ref weeks before any engine build accepted it. Nothing failed until a scenario with a ref was adopted in production, and then every one of them failed at argument parsing, ledgered only as a turn that produced no result.

[RFC-0004:C-BOUNDARY-INVENTORY] The controller-engine boundary (Informative)

The controller reaches the engine in two ways today, and this RFC treats them differently.

Renders and compilations are pure: given a profile, a manifest or workflow source, and a set of options, they produce a document (pod objects, a pack ConfigMap, a workflow graph, a parameter schema, a flow document). Today the controller spawns the engine executable for each of these and parses its stdout: turn-pod render, run-pod render, workflow compile, parameter schema, and flow.

Process launches are not pure: an agent turn or a plan run holds a checkout, spends money, streams a session, and must be killable. Today the controller spawns the engine for these too: scope, grounded rank, a local playbook run, and artifact fetch.

Both paths share one contract surface, the engine's command line, and one enforcement point, the engine's argument parser at dispatch time.

Three version-bearing things take part, and the clauses below name them consistently:

  • the contract definition: the single shared description of every document that crosses the boundary, compiled into both sides, carrying the contract version;
  • the linked engine revision: the engine source revision a controller build compiles its renders from;
  • a dispatch image: an engine image the running controller launches pods or processes from. A controller may be configured with more than one (loop images, sandbox images, per-pack images), and a dispatch image can differ from the linked engine revision because images are pinned in deployment configuration, not at build time.

This RFC replaces the render path with in-process renders checked at build time, and constrains launches to a typed request whose contract version is checked before any work happens.


2. Specification

[RFC-0004:C-LINKED-RENDER] Renders are linked, not spawned (Normative)

A render or compilation the controller needs (a turn pod, a run pod and its pack ConfigMap, a compiled workflow, a workflow's parameter schema, a flow document) MUST be produced in the controller's own process from the linked engine revision. The controller MUST NOT obtain any of these by spawning an engine executable.

A controller build that requests a render option the linked engine revision does not define MUST fail to build; it MUST NOT be possible for such a controller to reach dispatch.

For the same inputs, a render produced in-process by the controller and one produced by the engine's own command line MUST be byte-identical. The existing rendered fixtures are the acceptance test.

A render MUST NOT depend on anything beyond the inputs it is handed (the profile, the pack, the workflow source, its options). A render MUST NOT resolve image tags to digests, contact a cluster, or read the process environment unless the caller passes that capability in explicitly; the controller decides when a render may reach the network.

This clause changes how a render is delivered, not what it contains: the parameter schema stays as RFC-0002:C-PLAYBOOK-PARAMS defines it and the compiled workflow stays as RFC-0001:C-WORKFLOW defines it.

Rationale: a render is a function of its inputs. A process boundary and an argument parser between the caller and that function add a contract nobody checks and remove build-time checking, which was the only tool able to check it.

[RFC-0004:C-TYPED-INVOCATION] Typed requests across a process boundary (Normative)

Where the engine MUST run as a separate process (an agent turn, a local plan run, work inside a pod), the request MUST be a single typed document described by the contract definition, carrying the contract version it was written under. The engine MUST reject a request whose document has an unknown field, or whose contract version is not equal to the engine's own, before it clones, spends, or writes anything, with an error that names the field or both versions.

The command line of such an invocation MUST carry only the request kind and the location of the request document; it MUST NOT carry request fields as flags. An environment variable MAY carry the document location where a file cannot (a pod wrapper), and the same rejection rules apply.

The engine's reply for an invocation MUST be a typed document under the same contract version, so that the controller parses a result the way it built the request, and a reply the controller cannot decode is a boundary failure per RFC-0004:C-BOUNDARY-FAILURE, not a missing result.

Rationale: a process boundary is unavoidable for work that must be isolated or killed, but the boundary can still be a document with a schema and a version instead of a flag list whose only validator is the parser on the far side.

[RFC-0004:C-CONTRACT-VERSION] One contract version, checked before dispatch (Normative)

The contract definition MUST carry exactly one contract version. Two contract versions match if and only if they are equal; there is no compatibility range. A controller build MUST record the contract version of the contract definition it was compiled against, and an engine build MUST record the one it was compiled against; both MUST be readable from the running executable and, for an image, from the image's labels.

A controller build whose linked engine revision was compiled against a different contract version than the controller MUST fail its build, so the mismatch is found in CI and not in production.

Because a dispatch image is pinned in deployment configuration and may differ from the linked engine revision, the running controller MUST, at startup and whenever a dispatch image pin changes, read the contract version from every dispatch image it is configured with and compare each against its own. It MUST expose the result per dispatch image (the image reference, both versions, and whether they match) on the same read endpoint that reports its effective configuration, so an operator can see a mismatch without reading logs.

A mismatch MUST NOT stop the controller from serving. The controller MUST refuse to launch a pod or a process from a mismatched dispatch image, and each refusal MUST be ledgered per RFC-0004:C-BOUNDARY-FAILURE. Pods already running when a pin changes are out of scope; they carry the request they were launched with.

Rationale: build-time checking removes drift for renders and for the linked revision; the runtime comparison covers the one place a link cannot reach, an image chosen by configuration, and turns a runtime surprise into a visible, explainable refusal.

[RFC-0004:C-BOUNDARY-FAILURE] Boundary failures are ledgered by name (Normative)

A failure at the controller-engine boundary MUST be recorded on the issue or run ledger with the request kind, the dispatch image or linked engine revision involved, the controller and engine contract versions, and the engine's own error text. It MUST NOT be reduced to a generic no-result outcome; the ledger entry alone MUST be enough to tell what was sent and why it was refused.

Boundary failures are of two kinds, and the ledger entry MUST say which:

  • contract rejections are deterministic: a rejected contract version, an unknown field, a render option the engine does not define, a reply the controller cannot decode because its shape is wrong. The same request against the same engine cannot succeed, so a contract rejection MUST NOT be retried automatically.
  • transport failures are not: a process killed before it replied, a truncated reply, an I/O error reading or writing the request document. These MAY be retried under the retry policy that already governs the launch in question, and the retry MUST be ledgered as a retry, not as a fresh attempt.

Rationale: the motivating failure was visible only to someone who read the raw ledger event; the summary said nothing, and the item stayed in its initial state as if never attempted. Retrying a deterministic rejection would have hidden the same fault behind a moving timestamp.

[RFC-0004:C-RUN-EVIDENCE] Pod evidence travels on a pod token (Normative)

A run's evidence is its session log, its captured files, its git memory, and its captured telemetry. A run pod MUST deliver its evidence to the controller's run evidence routes under /api/evidence/runs/{run}/{kind}, keyed by the run, never by the pod that produced it. The controller MUST store an upload as the run's own artifact; nothing at completion re-owns pod-keyed uploads to a run.

At dispatch the controller MUST mint one opaque pod token for the run and deliver it to the pod through the run's Secret, never in the pod spec, the launch row, or argv. The controller MUST store only a digest of its secret. A run's token grants exactly two things: writing the evidence of the run it was minted for, and reading the evidence of the run that run's launch reruns, when it reruns one. Every other route MUST refuse it. A run's token MUST expire no later than the run's ceiling allows the pod to live, and MUST be revoked when the run completes or fails.

A scope turn's evidence is its pack and its transcript. At dispatch the controller MUST mint a pod token for the turn's pod, delivered the same way. A turn's token grants exactly one thing: writing that pod's pack and transcript under /api/evidence/turns/{pod}/{kind}. Every route a pod reaches the controller on for evidence MUST sit under /api/evidence, so a deployment can expose pod evidence to remote clusters without exposing the rest of the API. The controller MUST store them keyed by the pod and MUST check each against the digest the turn's termination message reports before using it. A turn's token MUST be revoked when the turn is collected or fails to launch. No pod MUST be given a cluster-issued credential for delivering evidence.

Reading a run's session log, captured files, or git memory MUST also be open to any caller the human API admits, and a run the local executor produced MUST be served from its run directory when the controller stored no copy.

A rerun launched in a pod MUST name its parent as the parent's evidence URL on the controller, and the engine MUST fetch the parent's evidence over that URL into a directory laid out like a run's state directory before it reads the parent, with the same refusals as a parent read from disk.


3. Compatibility

[RFC-0004:C-MIGRATION] Migration and flag ordering (Normative)

Until a render is delivered in-process, adding or changing an option on an engine command the controller invokes MUST land in the engine first, and the controller change MUST NOT be released before a dispatch image carrying that engine is deployed. A controller change that would send an option the linked engine revision does not accept MUST fail the controller's build.

Moving a render in-process MUST NOT change the rendered document for the same inputs; the existing rendered fixtures are the acceptance test.

Existing process-launch kinds MAY keep their current command-line form during migration, but a new launch kind MUST be introduced only as a typed request per RFC-0004:C-TYPED-INVOCATION.

The contract version starts at the version the contract definition carries when this RFC is finalized. A controller or engine that cannot report a contract version MUST be treated as mismatched per RFC-0004:C-CONTRACT-VERSION.


Changelog

v0.1.0 (2026-08-25)

Initial draft

RFC-0005: Run supervisor services

Version: 0.1.0 | Status: draft | Phase: spec Owners: @Will Eaton


1. Summary

[RFC-0005:C-SCOPE] Scope (Informative)

A run supervisor service is a long-running process the run supervisor starts beside the engine to give its tasks a capability they cannot hold themselves. The standing example is cluster access: a connector that creates a disposable virtual cluster for the run and serves its API to the agent, while Kueue admission and host policy stay on the host. A JIRA or forge client is the same shape. Services are opt-in per workload: an autoresearch run that edits a control-plane resource may need a whole virtual cluster, while most agent workflows need no service at all and start none. Sandboxed tasks reach a service only through the OpenShell gateway, and command tasks reach it directly.

Services belong to the manifest, not to the workflow graph: the graph says what runs and in what order under RFC-0002:C-PLAYBOOK-SHAPE, and a service is part of the environment tasks run in. A task asks for one by naming it in needs under RFC-0002:C-PLAYBOOK-CAPS.

The broker is the built-in instance of this pattern, with a fixed capability set. This RFC covers services a manifest declares. The boundary it draws is the sandbox: command and evaluate tasks and hooks already share the run supervisor's reach, a service's credentials included.

[RFC-0005:C-TERMS] The run supervisor (Informative)

The run supervisor is the process that runs one workload's engine: it compiles the workflow, dispatches every task, holds the run's credentials, and owns the sandboxes its agent turns run in. There is exactly one per run. It is created when the run starts and torn down when the run ends, on a laptop as a process and in a cluster as a pod. The sandbox is the untrusted side of the boundary it draws; the controller, which launches runs, is not part of it. Earlier documents call the run supervisor the loop pod and its image the loop image.


2. Specification

[RFC-0005:C-SERVICE-DECLARATION] Service declaration (Normative)

A manifest of any workflow type MAY declare services as an array of [[service]] tables, extending the schema RFC-0001:C-MANIFEST defines and validated under its rules. A manifest that declares none runs exactly as it would without this RFC.

Each service MUST declare a name and a tool, and MAY declare arguments, a tool version requirement, and secrets. A name MUST match [a-z][a-z0-9_]* and MUST be unique among the manifest's services. The capability a service provides is "service." followed by its name, so no service can take a name the engine or the broker uses. Arguments MUST be a list of strings, passed to the tool's executable verbatim and never through a shell. The engine MUST reject an argument that names a path inside the pack or the workspace. A version requirement uses semantic-version requirement syntax. Each secret entry MUST name a secret and MUST state the external system it authorizes and the scope within that system.

The engine MUST refuse the run, naming the service and the field, when a declaration violates this clause, before compiling the workflow.

[RFC-0005:C-SERVICE-SECRETS] Service credentials (Normative)

The engine MUST deliver each secret a service names only to that service's process environment, and MUST itself write no secret value into the sandbox, the workspace, a task's inputs, a prompt, the session log, or a captured file.

A service's secrets are credentials under RFC-0001:C-CAPABILITY-DISCLOSURE, held by the service. The resolved disclosure MUST list each service as a relay: the tool, the secrets it draws with the system and scope each declares, the tasks it is exposed to, and the gateway endpoint it adds to their sandboxes. A secret granted at launch is covered only when a service in the manifest names it with equal or broader scope.

[RFC-0005:C-SERVICE-CAPABILITY] Services as capabilities (Normative)

A declared service provides the capability "service.". A task requests it through needs, which names one capability as RFC-0002:C-PLAYBOOK-CAPS defines.

A service is available when its tool is attested under RFC-0005:C-SERVICE-CATALOG and every secret it names was granted to the run. The engine MUST decide availability before any task is dispatched. A task that needs an unavailable service is handled exactly as RFC-0002:C-PLAYBOOK-CAPS requires: a required one truncates the plan and an advisory one is skipped with its dependents. The engine MUST report the unattested tool or the ungranted secret by name. A service that is available but fails to start or to stay up is governed by RFC-0005:C-SERVICE-LIFECYCLE.

[RFC-0005:C-SERVICE-LIFECYCLE] Service lifecycle (Normative)

The engine MUST start a service no later than the first dispatch of a task that needs it, and MUST NOT start a service no dispatched task needs. It MUST give the service the run identifier as CRUCIBLE_RUN_ID and a free loopback port as CRUCIBLE_SERVICE_PORT, and MUST NOT dispatch a task that needs the service until the service is ready: an MCP initialize handshake that succeeds for an MCP transport, any HTTP response for plain HTTP. The run identifier MUST be unique across runs and the same across a run's resumptions.

A service that is not ready within the engine's readiness bound, or has exited when a task that needs it is about to dispatch, MUST fail that attempt as a transport failure, retried under the run's transport policy; the task did not run. A service that exits while a task that needs it is running MUST fail that task's attempt as a transport failure. The engine MUST restart an exited service before the next dispatch that needs it, and after the engine's restart bound for one run MUST treat further attempts that need it as transport failures without restarting it.

One instance of each service runs at a time, shared by every task that needs it, epilogue tasks included. When the run ends, stops, or suspends, the engine MUST signal each service to stop, MUST allow it the engine's grace period, and MUST then kill it. A resumed run MUST start new instances. The engine MUST run services so that they do not outlive the run supervisor if it dies.

crucible check MUST print the readiness bound, the restart bound, and the grace period.

[RFC-0005:C-SERVICE-REACH] How tasks reach a service (Normative)

A service binds only the loopback port the engine gives it. The engine MUST expose each service through a forward of its own on the OpenShell gateway, and the forward MUST refuse any request that does not carry the per-run, per-service bearer credential the engine issued for it. The engine MUST give that credential only to tasks that need the service.

An agent task that needs a service with an MCP transport MUST be configured with it as an MCP server named "service.", at the forward's address under the gateway's host name, carrying the credential. For MCP over stdio the engine MUST bridge the tool's standard streams to that forward. A command or evaluate task that needs a service MUST receive the forward's address as CRUCIBLE_SERVICE_URL and the credential as CRUCIBLE_SERVICE_TOKEN, NAME being the service name in upper case. A task that does not need a service MUST receive neither.

A sandbox's egress policy MUST include a service's forward exactly when some task that sandbox runs needs the service. Tasks that share a session MUST declare the same needs, and the engine MUST reject a workflow in which they do not, naming the session.

[RFC-0005:C-SERVICE-RECORD] Identity, check, and log (Normative)

The run's identity digest MUST cover every resolved service: name, tool, the attested version and executable, arguments, secret names with their declared system and scope, and the engine's readiness, restart, and grace bounds. Secret values MUST NOT enter it. A resumed run whose catalog attests a different version MUST record the new resolution and MUST NOT reuse the old one.

crucible check MUST print each resolved service with its tool, attested version, transport, secret names, and the tasks that need it.

The session log MUST record when each service starts, on which port, becomes ready, and exits, with its exit status, whether it stopped or was killed, and the task whose dispatch started it.

Spend a service causes outside the run supervisor, such as cluster work it submits, is not counted against the run's cost ceiling; the target's own quota bounds it.

[RFC-0005:C-SERVICE-CATALOG] The service catalog (Normative)

The run supervisor attests the tools a service may run in a service catalog. In a run supervisor image the catalog is a document the image carries at a fixed path. On a run with no image, the catalog is the document the operator names in CRUCIBLE_SERVICE_CATALOG, and with none named no tool is attested. crucible check MUST print which catalog it read.

Each catalog entry MUST give the tool's name, the absolute path of its executable in the run supervisor, its version, and the transport it serves: MCP streamable HTTP at a stated path, MCP over stdio, or plain HTTP. It MUST state whether the tool creates resources outside the run supervisor and whether it accepts concurrent clients. By listing a tool, the catalog asserts that the tool binds only the loopback port it is given, labels anything it creates outside the run supervisor with the run identifier it is given, and reclaims those resources when signalled to stop. A tool whose entry says it does not accept concurrent clients serves one task at a time, and the engine MUST NOT dispatch concurrently two tasks that need it.

A tool is attested for a service when the catalog lists it by the declared name at a version the declared requirement admits. The engine MUST run a service only from the executable its catalog entry names, never from a path the pack or the workspace supplies.

[RFC-0005:C-SERVICE-HANDOFF] Credentials a service hands a task (Normative)

A service MAY hand a task that needs it a credential for a target the service created for this run, such as the API of a virtual cluster it provisioned. The engine MUST deliver such a credential only into the sandbox of a task that needs the service, at a path the catalog entry states, and MUST NOT deliver it to any other task. The credential MUST be disclosed under RFC-0001:C-CAPABILITY-DISCLOSURE as entering agent context, with the target and scope the catalog entry states for the tool.

A service that hands off credentials MUST hand off only credentials scoped to targets it created for the run and reclaims when signalled to stop, which its catalog entry asserts. A credential for the run supervisor's own cluster, or for anything the run did not create, MUST NOT be handed off.


Changelog

v0.1.0 (2026-09-28)

Initial draft

RFC-0006: Blueprints

Version: 0.1.0 | Status: draft | Phase: spec Owners: @Will Eaton


1. Summary

[RFC-0006:C-SCOPE] Scope (Informative)

A blueprint is a registered playbook its owner offers as a starting point. A user picks one from the catalog and gets a draft of their own, seeded from the blueprint's pack, with the owning team's secrets already bound when they are on that team, and a placeholder for each secret they bring themselves. The draft is then theirs to edit, launch, and publish like any other.

This RFC adds to the ownership, sharing, and policy rules of RFC-0003; it does not change who may read, launch, or bind anything outside a blueprint's use.


2. Specification

[RFC-0006:C-BLUEPRINT] Blueprints (Normative)

A blueprint MUST be a registered playbook its owner has marked as a blueprint; a draft, a pack import, or an unregistered pack MUST NOT be one. Marking and unmarking MUST be the update action on the playbook. Marking MUST be refused unless the caller holds bind on every secret whose binding on the playbook would carry under RFC-0006:C-CARRY, and the refusal MUST name those secrets the caller may read and count the rest. A blueprint's revision MUST be the playbook's registered revision. Unmarking MUST NOT change a draft already created from the blueprint or a binding already carried.

Marking is checked against bind because it makes bindings already on the playbook carry. A binding added after marking is made by someone who holds bind and is told it carries.

[RFC-0006:C-CATALOG] Catalog (Normative)

The controller MUST serve a catalog listing the blueprints the caller may read and no others, under RFC-0003:C-READ-SCOPING. Each entry MUST give the blueprint's id, description, owner, current revision, and whether it is public, and for each secret its pack declares, whether that secret would carry to the caller or become a placeholder under RFC-0006:C-PLACEHOLDERS.

A team's blueprints appear to its members, a viewer share under RFC-0003:C-SHARING lists one for another principal, and a public blueprint under RFC-0006:C-PUBLIC appears to everyone.

[RFC-0006:C-PUBLIC] Public blueprints (Normative)

Making a blueprint public or private MUST be the share action on the playbook, and MUST be audited as a share grant or revocation under RFC-0003:C-AUDIT. In addition to the grants of RFC-0003:C-POLICY, the default policy set MUST grant every authenticated user principal read on a public blueprint, and publicity MUST NOT confer any other action or the read of the blueprint's shares. Only a blueprint MAY be public: unmarking a blueprint MUST make it private. Making a blueprint private MUST NOT change a draft already created from it or a binding already carried.

Publicity widens who sees a blueprint, not what carries: RFC-0006:C-CARRY still requires the user to be in the owning team, so everyone else gets placeholders. The platform administrator team's public blueprints are the catalog every user starts with.

[RFC-0006:C-USE] Using a blueprint (Normative)

Using a blueprint MUST create a new draft, and MUST require read on the blueprint and create of a draft for the chosen owner under RFC-0003:C-OWNERSHIP. The request MUST name the new draft's id and MAY name the blueprint revision it expects; a request whose expected revision is not the current one MUST be refused. The draft MUST be seeded from the blueprint's current revision and MUST record the blueprint id and revision as its origin. The response MUST list each binding carried and each placeholder created, with the placeholder's reason. A refused use MUST leave no draft and no binding.

[RFC-0006:C-CARRY] Carried bindings (Normative)

A binding on a blueprint carries to the new draft when all of these hold: the blueprint's owner is a team; the bound secret's owner is that team; the request's subject set includes that team; the binding is not restricted to a reviewed pack revision; and every check a direct bind of the secret by that subject makes, other than bind itself, passes. A carried binding MUST bind the same secret under the same declared name, projection kind, and projection, with the new draft as its scope. Creating it MUST be decided as bind on the secret for the request's subject, with the source blueprint in the request. In addition to the grants of RFC-0003:C-POLICY, the default policy set MUST permit that decision whenever the conditions above hold. No other way of creating a draft carries a binding under this clause.

Each carried binding MUST record its origin, the source blueprint and source binding; MUST be audited as a bind whose actor is the request's actor; and MUST be listable by origin by any subject that may read the secret. A carried binding is independent of its source once created: unbinding or rebinding either MUST NOT change the other. A response that creates a binding on a blueprint MUST state whether that binding carries.

Binding a team secret to the team's blueprint lets every member use it from a draft they can edit. Members already launch with it from the team's own playbooks; carrying extends that to code they change, so a binding restricted to reviewed code never carries and each copy names its source.

[RFC-0006:C-PLACEHOLDERS] Placeholders (Normative)

Each secret the blueprint revision's pack declares that does not carry MUST become a placeholder on the new draft. A placeholder MUST give the declared name, the projection kind and projection of the blueprint's binding when one exists, and the first reason that applies of: no binding on the blueprint; a binding restricted to a reviewed revision; a secret owned by a user; a secret not owned by the blueprint's owner; a team the request's subject set does not include; a bind the active policy set denies; a check of a direct bind that did not pass. A placeholder MUST NOT reveal the bound secret's name, owner, or storage location to a subject that may not read that secret.

A placeholder MUST be filled by an ordinary bind on the draft, which requires bind on the secret the user chooses, and that bind SHOULD default to the placeholder's projection. Reading the draft MUST list its unfilled placeholders. A placeholder MUST be dropped once the draft binds its declared name or its pack stops declaring it. A launch of a draft with an unfilled placeholder MUST be refused naming the placeholder.

A personal secret never leaves its owner: the blueprint passes on what to bind, not what was bound.


Changelog

v0.1.0 (2026-11-02)

Initial draft

Architecture decision records

The ADRs capture the load-bearing design calls behind crucible: why the loop is shaped the way it is, what was traded off, and what the alternatives were. They are ordered, append-only, and meant to be read when you want the why rather than the how.

Each page is rendered by govctl from its source in gov/adr/, and its header carries the record's status (proposed, accepted, superseded). Edit the TOML source and run govctl render adr; CI fails when a page drifts from its source. govctl adr list prints every record with its status.

Expand this section in the sidebar to browse the full list.

ADR-0001: Adaptive harness — adapt the setup, freeze the judge

Status: accepted | Date: 2026-06-23

References: RFC-0001

Context

The loop's integrity rests on one thing: the gate (fitness) must be trustworthy and outside the agent's reach. A kept "win" is only as honest as the harness that judged it. Tailoring the harness to each issue is valuable, but adaptivity aimed at the wrong layer turns the loop into a reward-hacking machine — if the harness can reshape itself toward the thing being optimized, the agent (or the adaptation itself) will make the workload easy, move the baseline, or pick a metric it already passes.

Decision

Adapt the setup. Freeze the judge. The agent may change its solution; it must never change its evaluation. Split the system into two phases with a hard wall: (1) Scoping (adaptive, once, then frozen) — analyze the issue and decide the workload, baseline, and gate; output is pinned, content-hashed, validated via gate self-test, and human-approved. (2) The loop (immutable gate) — the optimizing agent runs against the frozen harness with only solution-space tools and a read-only gate. Every tool is classified as either evaluation/setup (frozen, not in the loop agent's toolbox) or solution (agent may use). Safeguards: gate self-test with negative controls, harness fingerprint on every result, fixed baseline derived once during scoping, multi-seed gating, and cost/blast-radius gating. The agent may declare the judge inadequate via a structured escalation (escalate --category ... --reason ... --evidence ...) which stops the loop and surfaces the report for human review, but it can never edit the evaluation.

Consequences

Positive: keeps the appealing part — the harness meets each issue where it is — while the optimization loop stays trustworthy and its results stay comparable and reproducible. Clear capability boundary; the loop agent literally cannot reward-hack its evaluation because it has no tool to reach it. Negative/cost: scoping becomes a real, gated step (validation + approval), not a side-effect of running the loop. The loop's tool wiring splits the toolbox into setup vs solution, emits a harness manifest + fingerprint, and runs the gate self-test as a scoping precondition.

Alternatives Considered

Adapt the setup, freeze the judge — two-phase system with a hard capability wall between scoping (adaptive) and the loop (immutable gate) (accepted)

  • Pros: Keeps harness tailoring while the optimization loop stays trustworthy, Clear capability boundary prevents reward-hacking, Results remain comparable and reproducible via fingerprinting
  • Cons: Scoping becomes a real gated step requiring validation and approval, Tool wiring complexity increases with the setup/solution split

Allow the agent to adapt the judge during the loop — no phase boundary between scoping and optimization (rejected)

  • Rejected because: Turns the loop into a reward-hacking machine; the agent or adaptation can make the workload easy, move the baseline, or pick a metric it already passes

ADR-0002: Mediated provisioning — the agent asks, a server-side MCP admits

Status: accepted | Date: 2026-06-25

References: ADR-0001, RFC-0001

Context

ADR-0001 drew the trust wall: the agent may change its solution, never its evaluation. But a blocked agent has a legitimate need the loop can't yet serve cleanly — it discovers it must measure a regime it has no calibrated trace for (a new model, a higher request concurrency, a novel workload), and the only honest GPU-free answer is capture/calibrate a new trace. Today that means either the agent reaches the cluster directly (unacceptable — we will not give the sandboxed agent direct authority to spend GPU, submit a Kueue Workload, or dispatch a GitHub Actions workflow) or it writes ESCALATION.json and the whole run halts for a human (too coarse for a need that is often grantable). The missing piece is a mediation layer: a place that holds the privilege and the admission logic, exposes only a narrow "ask" to the agent, and decides whether/how to grant.

Decision

The agent asks; a server-side MCP on the loop pod admits. Provisioning privilege and the admission logic live in the MCP, never in the sandbox. Six concrete decisions: (1) Privilege separation by identity — solution-space ops stay direct under the agent's scoped rig credential, provisioning ops are removed from the agent's reach and gated by the MCP's own identity. (2) The MCP is the only privileged endpoint the sandbox can reach — OpenShell egress is deny-by-default with only the loop pod's MCP endpoint allowlisted. (3) Requests are classified by whether they touch the judge: in-scope (cache fill, no judge change) resolves synchronously; judge-changing triggers a governed re-scope (re-derive baseline, bump fingerprint, run gate self-test). (4) Admission gating is server-side and opportunistic with GPU headroom logic. (5) Human-in-the-loop is pluggable and headless-first via two interchangeable approval backends: forge-native draft PRs (headless default) or control-channel operator keystroke (attended). (6) Degraded mode is escalate-halt, never auto-admit — when a judge-changing request arrives with no approval surface reachable, it falls back to the escalate hatch.

Consequences

Positive: the agent literally cannot provision — no GPU spend, no Kueue, no CI dispatch from the sandbox — yet it is no longer dead-ended on a grantable need. The choke point is one auditable server-side process. Admission and approval are policy, not code scattered across tools; headless runs get a real forge-native async CODEOWNERS-gated approval path, attended runs keep the keystroke path, both record provenance. Reuses existing infrastructure. Negative/cost: a real MCP server on the loop pod with lifecycle and concentrated privilege to guard, MCP plumbing into the sandbox config, and an egress allowlist entry. RBAC must be tightened so the agent identity loses any latent provisioning rights. The judge-changing re-scope is real engine work.

Alternatives Considered

Server-side MCP broker as the sole privileged mediation layer between agent and cluster provisioning (accepted)

  • Pros: Agent literally cannot provision — no GPU spend, no Kueue, no CI dispatch from the sandbox, Admission and approval are policy in one auditable server-side process, Reuses existing infrastructure: control bridge, gpu-check, request-trace cache, session MCP
  • Cons: Real MCP server on the loop pod with lifecycle and concentrated privilege to guard, Judge-changing re-scope (auto re-baseline + fingerprint + gate self-test) is real engine work

Give the agent direct cluster access (kubeconfig for capture namespace, forge token in sandbox) (rejected)

  • Rejected because: Unacceptable blast radius — gives the sandboxed agent direct authority to spend GPU, submit Kueue Workloads, or dispatch CI; privilege cannot be scoped narrowly enough

ADR-0003: Async approval waits — the agent decides block vs continue

Status: accepted | Date: 2026-06-25

References: ADR-0001, ADR-0002, RFC-0001

Context

ADR-0002 specified the approval mechanism (draft PR, poll for /approve-capture) but not what the run does between the ask and the answer, nor how it resumes. The agent turn is ephemeral (a per-turn OpenShell sandbox torn down when the turn ends) and cannot sit and wait — making it spin-poll burns tokens on a paid sandbox to do nothing. The long-lived thing is the loop (crucible, the pod's entrypoint). Two things need pinning: (1) where the wait lives (the loop's, not the turn's), and (2) whether to block the whole run (only the agent has the context to know — sometimes the pending regime is its only path, sometimes it has other frozen-regime hypotheses to test).

Decision

The broker decides whether a regime change is allowed (privilege + evaluation, server-side, ADR-0002). The agent decides how to spend the wait (solution-space, ADR-0001). The loop owns the wait and the resume. Seven concrete decisions: (1) The wait is the loop's, not the turn's — on pending_approval the agent ends its turn, never spin-waits. (2) The agent chooses the wait mode via a marker: block (this regime is my only path, loop parks) or continue (I have other frozen-regime work, loop keeps iterating). (3) One wake mechanism for both modes — the broker watches approval and sends a rescope command to the control bridge when provisioning is ready. (4) The decision rides on a PROVISIONING_PENDING.json marker consumed by the loop post-turn. (5) Budget treatment follows the mode — block pauses the cost/time clock, continue keeps spending. (6) Two interchangeable human channels (forge draft PR or control-bridge keystroke), same rescope. (7) Deny/timeout is not a harness failure — the loop discards the regime change and resumes in the frozen regime, except when the agent declared block with no fallback, which escalate-halts.

Consequences

Positive: the run can genuinely wait for a human and resume, headless or attended, without the agent burning paid sandbox time. The block vs continue call sits with the only actor that has the context — the agent. Almost entirely reuses existing infrastructure: the wake is the existing re-scope path, the transport is the control bridge, the marker is the escalate pattern, the channels are ADR-0002's two backends. Negative/cost: a parked pod idles on a human (cheap — no tokens, paused budget — but a real pod-hour, bounded by max-park timeout). Continue mode interleaves frozen-regime and re-scoped segments in the same run; the session log models segments so the story stays readable, but scores across segments are not comparable.

Alternatives Considered

The loop owns the wait; the agent chooses block vs continue mode; the broker watches approval and sends rescope via the control bridge (accepted)

  • Pros: Run can genuinely wait for a human and resume without burning paid sandbox time, The block vs continue decision sits with the only actor that has the context — the agent, Almost entirely reuses existing infrastructure: re-scope path, control bridge, escalate pattern
  • Cons: A parked pod idles on a human (cheap but a real pod-hour); bounded by max-park timeout, Continue mode interleaves frozen-regime and re-scoped segments in the same run

Agent spin-waits inside the turn for the approval to resolve (rejected)

  • Rejected because: Turns are ephemeral and paid; spinning burns tokens on a paid sandbox to do nothing while waiting on a human

ADR-0004: Core-loop state model — context struct + typed iteration, not whole-loop typestate

Status: accepted | Date: 2026-06-25

References: ADR-0001, ADR-0003, RFC-0001

Context

run_loop has crossed a complexity inflection. It now threads ~13 mutable bindings across iterations and exits through five breaks plus two continues. ADR-0003 added park/rescope/deny/provisioning gates on top of the existing apply-measure-decide-keep/discard core. The next addition gets risky: the invariants (re-baseline mutates a coherent set of fields together; you can't measure before apply, can't keep without a Reading, can't measure on top of an escalation) live only in the reader's head. A typestate pattern was proposed; this ADR decides how much typestate and where.

Decision

Type the linear sub-protocol; keep a plain state machine with an explicit context for the cyclic shell. Three changes: (1) A Run context struct bundling per-run threaded state, with segment-scoped fields factored into a Segment (re-scope becomes one assignment that can't half-update the goalpost). (2) An explicit LoopExit enum replacing scattered breaks and the escalated flag. (3) Typestate on the inner iteration only — Proposed to Applied to Measured, where only a Measured can be decided/kept, making "keep without a reading" and "measure before apply" compilation errors. The outer loop stays a plain for over a mutable Run; gates run before an iteration enters Proposed.

Consequences

Positive: the re-scope can't half-update the goalpost (Segment swap is atomic); the iteration protocol's ordering invariants are compile-enforced; run_loop's signature and body shrink; the ways a run can end are enumerated in one enum. The next gate plugs into a named context and a typed iteration instead of adding a 14th threaded binding. Negative/cost: more types and self-consuming transitions are slightly more verbose than inline statements, worth it only for the iteration protocol. A refactor of a core file must land as one reviewed change with the test suite green.

Addendum (2026-09-05): change (3) is retired. The iteration now runs as a work-graph plan through the shared executor on every run; the graph path had been opt-in behind --graph-loop with a parity harness proving it matched the typestate path on decisions, rows, kept commits, and shutdown token. With the loop's control flow held in a transition table (runloop::machine) and the executor's own lifecycle tables (plan::machine), the ordering invariants the typestate enforced at compile time are enforced by the plan's declared dependencies and asserted at runtime, and a single Turn in the table matched a single implementation in the code. Changes (1) and (2) stand. One behavioral difference is deliberate: a transport-class turn death is retried inside the propose task before the driver counts a dead attempt, so a single dead turn followed by a success leaves no infra-dead row.

Alternatives Considered

Type the linear sub-protocol (iteration); keep a plain state machine with an explicit context for the cyclic shell — a Run context struct with a Segment sub-struct, a LoopExit enum, and typestate only on the inner Proposed-Applied-Measured iteration (accepted)

  • Pros: Re-scope can't half-update the goalpost (Segment swap is atomic), Iteration protocol ordering invariants are compile-enforced, run_loop shrinks to 'drive gates, run a typed iteration, fold the result into Run'
  • Cons: More types (Iteration zoo, marker structs) with self-consuming transitions are slightly more verbose, Refactor of a core file must land as one reviewed change with the test suite green

Whole-loop typestate (Loop, Loop, etc. with self-consuming transitions) (rejected)

  • Rejected because: Nearly all state is shared and threaded through every phase, so a per-phase type would carry one big shared context plus a marker — the ceremony of typestate with none of the safety

Run every iteration as an executor-walked plan: the driver keeps the gates and folds the result, the plan executor owns propose-apply-measure-decide ordering (adopted 2026-09-05 once the graph path had soaked behind --graph-loop and its parity harness passed) (accepted)

ADR-0005: Engine-side EPP builds, brokered to the agent over MCP

Status: accepted | Date: 2026-06-26

References: ADR-0001, ADR-0002

Context

The first domain that needs in-loop EPP code rebuilds (#1109) exposed that the sandboxed agent cannot build or see the measurement. It can't build because build-image shells out to an out-of-sandbox remote-build script, and a kaniko build needs build-namespace kubectl, a push credential, and egress the deny-by-default sandbox does not have. It can't see the measurement because the sandbox predates the TTFT bench, so the agent reverse-engineers the binary and spirals. Putting build creds in the sandbox enlarges the attack surface and fights the World trait, which already says build+deploy is apply's job (engine-side).

Decision

Move build+deploy off the agent to the loop pod (engine-side), and expose it to the agent as broker (MCP) tools — the ADR-0002 pattern. The agent calls build_epp and deploy_candidate over the existing MCP bridge; the loop pod runs the container build (buildah) with its own push credential for the candidate registry and returns compile errors or success. A backstop apply_cmd ensures the latest built candidate is deployed before the judge measures. The agent's sandbox loses build creds entirely — it only edits code and calls the broker. Components to build: loop-pod build capability, epp-mcp build tools, workspace sync (the agent commits and pushes its branch to the loop pod's workspace as a git remote), rebuilt sandbox image with current tools, and manifest wiring.

Consequences

Positive: build creds never enter the sandbox (ADR-0001/0002 aligned); the agent gets real compile feedback; the loop pod is the one trusted place for push creds; apply_cmd guarantees the measured candidate is the built one; code-change domains become first-class. Negative/cost: real infra needed (push cred, RBAC, builder in the loop image, workspace sync mechanism, sandbox rebuild) and per-iteration build latency.

Alternatives Considered

Move build+deploy off the agent to the loop pod (engine-side) and expose it as broker MCP tools — the agent asks, the loop pod holds the keys (accepted)

  • Pros: Build creds never enter the sandbox (ADR-0001/0002 aligned), Agent gets real compile feedback within the turn, Code-change domains become first-class, not just config tuning
  • Cons: Real infra needed: push cred, RBAC, builder in the loop image, workspace sync mechanism, Per-iteration build latency

Put build credentials directly in the sandbox so the agent builds itself (rejected)

  • Rejected because: Enlarges the attack surface and fights the World trait which says build+deploy is the engine's job

ADR-0006: Profiler support over MCP — a generic capability, pprof for the EPP

Status: accepted | Date: 2026-06-26

References: ADR-0002, ADR-0005

Context

The TTFT gate cannot isolate the EPP or resolve small EPP wins. The measured path includes Envoy's body handling, data-plane transfer, backend ingest, and queueing at c=150. Every micro-optimization the agent tried came back "within noise" because it was optimizing a low-single-digit percent sliver that the c=150 measurement noise swamps. The gate is right for scoring a candidate but wrong for finding what to change. A profile (pprof) attributes CPU/allocations/contention to specific EPP functions and source lines, isolated by construction and far more sensitive than a noisy end-to-end delta. The EPP ships pprof but the port is token-gated on the secure metrics server and the sandbox has no cluster reach or Go toolchain.

Decision

Add a generic Profiler capability to the broker — mediated "observe the candidate's internals" — with the concrete profiler as a domain-configured backend. Two generic tools: profile(kind, seconds) captures a profile by shelling BROKER_PROFILE_CAPTURE_CMD, and profile_query(query) runs a query against the stored profile by shelling BROKER_PROFILE_QUERY_CMD. The broker knows nothing about pprof, Go, or the EPP — only "capture a profile, then query it," delegated to configured commands. The EPP domain sets the two commands (capture = authed GET of the pprof endpoint; query = go tool pprof). Extensible to multiple targets via BROKER_PROFILE_TARGETS for composite deployments, with per-target capture/query/extension commands. The loop becomes: profile to find the hot lines, fix them, re-profile to confirm the hotspot shrank, then measure to score.

Consequences

Positive: the agent optimizes the measured hot path instead of theorizing in the noise; the isolation/sensitivity problem the TTFT gate cannot solve is solved by direct observation; generic across domains (pprof, torch-trace, py-spy, eBPF); composes with measure/build/deploy; strengthens the case for broker extraction. Negative/cost: small RBAC grant, a binary in the loop image, two more broker tools, and a rebuild; profiling adds minor overhead during capture; the query pass-through needs a deny-list to prevent the agent from starting servers or writing files.

Alternatives Considered

Generic Profiler capability in the broker with domain-configured backends (BROKER_PROFILE_CAPTURE_CMD / BROKER_PROFILE_QUERY_CMD), concrete pprof backend for the EPP (accepted)

  • Pros: Agent optimizes the measured hot path instead of theorizing in the noise, Generic: any domain can swap in py-spy, perf, eBPF, or torch-trace via the same two env commands, Composes with measure/build/deploy and strengthens the case for broker extraction
  • Cons: RBAC grant, binary in the loop image, two more broker tools, and a rebuild, Needs a command deny-list so the agent cannot start servers or write files via profile_query

Give the agent direct access to the pprof endpoint from the sandbox (rejected)

  • Rejected because: The sandbox has no cluster reach (egress wall) and no Go toolchain; would require punching holes in the egress policy and baking Go tools into the sandbox

ADR-0007: Isolation pre-flight — verify the gate isolates the target before spending a loop

Status: accepted | Date: 2026-06-26

References: ADR-0001, ADR-0006

Context

#1109 (long-context requests see >1s TTFT overhead in the EPP request path) ran as a loop and produced two valid-but-flat candidates. ADR-0006's profiler showed the EPP is ~24% of one core busy with GC incidental. The EPP's actual work is ~20-30 microseconds per request; the 2.26s TTFT is the ext_proc handler sitting blocked waiting on Envoy's body buffering, data-plane transfer, mock backend, and queueing — none of which the EPP controls. The request_duration metric (~2.26s) measures handler attachment wall-clock time, not compute. The gate is end-to-end and cannot attribute a result to the EPP, so every EPP-side candidate ties in the noise by construction. Root cause is world-scoping (the 220KB-body mock drowns the EPP in Envoy transfer cost), not the EPP.

Decision

Onboarding must verify the gate actually isolates the target and that the target's contribution is material before spending a loop. Three parts: (1) Attribute the gate — before the first scored iteration, measure the target's own contribution to the gated metric using the target's instrumentation; if the target is a sub-percent sliver of the gated number, the loop cannot win, escalate as mis-scoped. (2) Distinguish work from wait — prefer a compute/decision metric or a profile for attribution; never infer "the component is slow" from a duration that includes downstream wait. (3) Re-scope or escalate — a real experiment needs a world where the target's contribution is material. #1109 is therefore escalated (no EPP-side win exists as gated).

Consequences

Positive: a ~10-minute instrumented pre-flight kills an unwinnable run before it spends a paid loop; the autoresearch stack surfaces a mis-scoped problem instead of grinding; the check is generic (any target with self-instrumentation) and slots into the judge-validation gate; it converts "the loop keeps tying in noise" from a mystery into a one-number verdict. Negative/cost: another gate in onboarding (the pipeline must learn to read a target's attribution metric); a per-target choice of "which metric attributes the gate" is not always obvious. Carries forward: empirical case for the world-scoping discipline — the judge can be perfectly frozen and still measure the wrong thing if the world doesn't isolate the target.

Alternatives Considered

Require an isolation pre-flight in onboarding that verifies the gate actually isolates the target and that the target's contribution is material before spending a loop (accepted)

  • Pros: A ~10-minute instrumented pre-flight kills an unwinnable run before it spends a paid loop, Generic (any target with self-instrumentation) and slots into the judge-validation gate, Converts 'the loop keeps tying in noise' from a mystery into a one-number verdict
  • Cons: Another gate in onboarding; the pipeline must learn to read a target's attribution metric, Per-target choice of 'which metric attributes the gate' is not always obvious

Skip the pre-flight and rely on the loop to discover isolation failures during iteration (rejected)

  • Rejected because: Wastes an entire paid loop discovering what a ~10-minute pre-flight can determine — the agent doom-loops against noise

ADR-0008: Domains as immutable composes — the rpm-ostree model for the frozen world

Status: accepted | Date: 2026-06-26

References: ADR-0001, ADR-0005, ADR-0006, ADR-0007

Context

Launching the first two local go-test domains surfaced a cluster of failures sharing one root cause: runtime mutation of the environment drifting from what was authored against. libgit2 had no TLS, image Go was wrong version, the harness didn't match the code signatures, a stale baked workspace caused wrong commit, cold go mod download caused crashes, and the agent sandbox had no Go toolchain. The judge was frozen (ADR-0001), but the world it runs in was not: the loop clones a moving target, downloads deps on the fly, and gets patched at runtime. A frozen judge measuring an unfrozen world measures different code on every run, which also makes cross-run memory meaningless.

Decision

Treat each autoresearch domain as an rpm-ostree-style immutable compose. The world — upstream source@SHA + matched toolchain + pre-fetched dependencies + frozen judge harness — is composed ahead of time into a single content-addressed image. The loop runs that image; it does not clone, fetch, or get patched at runtime. To change the world you re-compose and rebase to a new digest, atomically. The agent's edits are the only mutable layer, realized as git commits on top of the pinned SHA (the CoW overlay). The compose input digest (a hash-of-hashes over source SHA, toolchain, go.sum, frozen harness, and recipe) is the canonical identity for cross-run memory. Dependencies are part of the overlay and can be added mid-turn; the toolchain is hard-frozen and requires a re-compose to change. Detection is continuous, application is deliberate via a Kubernetes-shaped reconcile loop.

Consequences

Positive: the build/world-definition mismatch class is designed out (cross-run memory compares identical bytes); measurement is offline and fast (no per-run dep fetch); validation moves to build time (fail the compose, not the paid loop); it is the natural unit for the controller to manage and version. Negative/cost: a per-domain compose build and re-compose to pick up upstream changes (deliberate, atomic); larger images with deps baked in; the agent-edits-as-overlay relies on git history on top of repo@SHA; pinning means the kept patch is against SHA not live main, so upstreaming a win is a separate rebase. Supersedes the runtime patching (emptyDir, configMap) used for the first launch.

Alternatives Considered

Treat each domain as an rpm-ostree-style immutable compose — source@SHA + toolchain + deps + frozen harness composed ahead of time into a content-addressed image; the agent's edits are the only mutable overlay (git commits on top of the pinned SHA) (accepted)

  • Pros: Build/world-definition mismatch class is designed out, not patched around, Cross-run memory finally compares identical bytes; two runs of the same compose input digest compile identical bytes, Measurement is offline and fast (no per-run dep fetch); validation moves to build time
  • Cons: Per-domain compose build and re-compose to pick up upstream changes (deliberate, atomic), Larger images (deps baked in); pinning means the kept patch is against SHA, not live main

Always clone fresh main at runtime and download deps on the fly (rejected)

  • Rejected because: Re-introduces the moving-target drift this ADR exists to kill; cross-run memory compares different bytes; network-dependent and slow

ADR-0009: Composite domains — combined multi-component autoresearch

Status: accepted | Date: 2026-06-27

References: ADR-0001, ADR-0002, ADR-0006, ADR-0008, RFC-0002

Context

Every domain so far is single-component: one upstream repo, one judge, one world. That shape cannot express the cross-cut features that matter most in llm-d, where a behavior is split across the router (EPP) and the engine (vLLM) and neither side's gate can see the payoff. Two real examples: (1) LoRA-aware routing — vLLM exposes adapter residency metrics but the EPP cannot distinguish warm from cold adapters, and the win (avoided cold-load TTFT) is only measurable end-to-end across the EPP-vLLM path. (2) P/D rollout protection — during a rolling update the EPP must avoid pairing prefill/decode endpoints with incompatible KV-transfer configs, but vLLM exposes no config hash for filtering. Both share a shape: vLLM exposes a signal, the EPP routes/filters on it, and the fitness is a property of the assembled system. A single-component manifest cannot represent this.

Decision

Introduce composite domains: a domain that composes N component domains into one autoresearch run. A composite reuses the component domains (each an ADR-0008 immutable compose) and adds three things: (1) A multi-workspace world — the agent may edit any component's checkout in a turn; keep/discard and git memory span all components as a tuple of overlay commits. (2) A single combined gate — one measure_cmd that stands up the assembled system and runs the frozen cross-cut workload. (3) The union of component surfaces — both components' tools, skills, and profiler backends available in one run. The engine stays component-agnostic: "combined" is a composite manifest plus a combined gate command, not per-feature code. Run identity extends to a vector of per-component (digest, overlay) tuples plus a cross-cut workload hash.

Consequences

Positive: the cross-cut feature class becomes optimizable at all; the win is measured where it actually exists (end-to-end); the engine gains one general capability (multi-workspace runs) rather than per-feature code; component packs are reused verbatim. Negative/cost: the heaviest rig to stand up and keep reversible; multi-workspace git memory + tuple run-identity is new engine work; the combined gate is bespoke per cross-cut feature (authoring, not engine); a re-compose of any component retires the composite's cross-run memory epoch. Composites are opt-in for exactly these costs. Validates the component-agnostic discipline (generic broker, name-prefixed tools, measure_cmd as the sole contract). RFC-0002 carves out the unscored case: a composite whose workflow type is "playbook" carries no combined gate and produces no combined score, because it composes work rather than fitness. The combined-gate requirement above governs a composite that is optimizing something, which is every composite this ADR was written for.

Alternatives Considered

Introduce composite domains: a domain that composes N component domains into one autoresearch run with a multi-workspace world, single combined gate, and union of component surfaces (accepted)

  • Pros: Cross-cut feature class becomes optimizable — measured where the win actually exists (end-to-end), Engine gains one general capability (multi-workspace runs) rather than per-feature code, Component packs are reused verbatim; a composite is mostly a manifest + a combined gate
  • Cons: Heaviest rig to stand up and keep reversible (GPU P/D + EPP + rollout/mix harness), Multi-workspace git memory + tuple run-identity is new engine work

Vendor EPP into vLLM (or vice versa) to create a merged single-repo domain (rejected)

  • Rejected because: Each stays its own upstream repo with its own toolchain (Go vs CUDA/Python) and compose; the kept patch must upstream to its repo as a normal PR. A composite is an assembly of frozen worlds, not a third repo.

ADR-0010: Candidate portfolios — explore/exploit search over reviewable candidates

Status: accepted | Date: 2026-06-28

References: ADR-0001, ADR-0004, ADR-0005, ADR-0009, RFC-0001

Context

The loop today (ADR-0004) is a single line of descent: iteration N refines iteration N-1, pure exploit making one approach better. Running the composite P/D rollout-protection goal one-shot showed two things: (1) cold, in one turn, the model found the correct cross-cut architecture from scratch; (2) a single shot is one sample from a wide space — the same goal has architecturally distinct solutions with very different blast radius, and the model "knows" several of them. Betting the whole run on the first one it picks throws away the others, and "which is best" is not an eyeball question. The competing approaches range from metrics-scrape to pod-label to EPP-only derivation to sidecar handshake to generation-affinity to reactive dead-peer detection — these are different families, not refinements of each other, and the frozen gate can actually rank them.

Decision

Generalize the loop from a single line of descent to a candidate portfolio with two explicitly different search modes, ranked by the frozen judge, and surfaced as reviewable PRs. (1) Wide/explore: N independent candidates in parallel, each a different angle via distinct method prompts, each with minimal context in its own sandbox. Goal: discover which families clear the gate. (2) Deep/exploit: take the gate's top-k and iterate them sequentially with full context (prior diff, measured score, what failed). This is ADR-0004's loop applied to a seeded starting point. The default run is wide-then-deep (a tournament with a refinement final). The judge is the ranker (ADR-0001). Candidates are reviewable draft PRs per edited component, cross-linked for composites. Deploy-to-rank (ADR-0005) is the gate to real scores. Propose turns run in parallel (worktree-per-candidate) while measurement serializes on the shared rig. Diversity must be engineered via distinct method prompts, not auto-generated.

Consequences

The loop gains a population and a (wide, deep) schedule; ADR-0004's Segment/Iteration typestate becomes the deep leg, and a new fan-out drives the wide leg over the same diff-capture and PR-publish plumbing. Cost scales with breadth: N wide candidates = N sandboxes (parallel) + N deploys to rank. Diversity must be engineered (distinct method prompts/forced approaches); N identical turns are not a portfolio. "Solved" is still the gate's call, now over the best of a population rather than the end of a single line.

Alternatives Considered

Generalize the loop to a candidate portfolio with two search modes (wide/explore for diverse independent candidates, deep/exploit for sequential refinement of winners), ranked by the frozen judge and surfaced as reviewable PRs (accepted)

  • Pros: Discovers which approach families clear the gate; does not bet the run on the first approach, Wide round costs ~one turn of wall-time via parallel sandboxes; deep round refines the winner, Candidates are reviewable PR artifacts, cross-linked for composite domains
  • Cons: Cost scales with breadth: N wide candidates = N sandboxes + N deploys to rank, Diversity must be engineered (distinct method prompts/forced approaches), not auto-generated

Single-line sequential refinement only (status quo) (rejected)

  • Rejected because: Bets the run on the first approach with no way to compare families — exactly what the one-shot experiment exposed

One turn self-enumerates and implements K approaches (rejected)

  • Rejected because: K attempts share one context and one framing so they cluster; weaker diversity for the same review/rank cost

ADR-0011: Crucible-rendered deployments — generate the loop/broker/rig manifests from the manifest + a deploy profile

Status: accepted | Date: 2026-06-28

References: ADR-0002, ADR-0005, ADR-0008, ADR-0009, RFC-0001

Context

Loop pod specs (~250 lines YAML each) duplicate manifest values, require manual digest pinning, and are ~90% identical across domains. Three structural problems drove this decision: pod env duplicates the manifest (every manifest field change requires a matching YAML edit), manual digest pinning is error-prone (stale digests caused production incidents), and the pod spec is copy-pasted across domains with hand-maintained divergence.

Decision

crucible deploy render/apply generates the loop pod + broker wiring + RBAC from two inputs: the domain manifest (already the source of truth) and a thin deploy profile (environment-specific facts like cluster endpoints and image overrides). The renderer projects manifest config into pod env, resolves and pins image digests at render time via oci-client, shares a template per backend, and emits RBAC references. Implemented as a native Rust renderer with real k8s-openapi types, including a kube-rs migration that removed the kubectl dependency from the engine.

Consequences

Positive: one source of truth (manifest edit + re-render replaces yaml safari). Reproducible deploys with digests computed at render time, eliminating the stale-binary bug class. Composes cleanly with the controller. Lowers domain onboarding bar.

Negative: the renderer must track every manifest field the pod spec consumes, and a new field requires a renderer update. The deploy profile is a new artifact to maintain per environment.

Alternatives Considered

Native Rust renderer with k8s-openapi types, projecting manifest config into pod env with digest pinning at render time (accepted)

Use kustomize or Helm templates to generate deployment YAML (rejected)

ADR-0012: Scoping as a governed pipeline

Status: proposed | Date: 2026-07-02

References: ADR-0001, ADR-0007, ADR-0008, ADR-0010, RFC-0001

Context

Every run starts from a hand-authored harness (~one day expert work per issue), the throughput bottleneck. Also the most dangerous manual step: #1109 burned a loop on a gate that could not isolate its target, #1489's reward hack lived in a gate that under-counted a path. ADR-0001 designed the answer (adaptive scoping, once, then frozen) but only the freeze was built; the scoping automation remained manual.

Decision

Five-stage pipeline via crucible scope: 1) Ingest and triage (goal extraction, judge-tier ranking T0/T1/T2/N), 2) Propose (one agent turn drafts the harness), 3) Validate mechanically (crucible check + gate self-test + isolation preflight), 4) Approve (draft PR, human signs off), 5) Freeze (manifest pinned, RunIdentity digest computed). The proposer is never the optimizer. Validation is mechanical. A human approves. The freeze is stamped.

Consequences

Positive: onboarding drops from a day to review-of-a-draft-PR. Two known harness failure modes (#1109 isolation, #1489 under-counting) caught by construction via gate self-test and isolation preflight. Gate self-test becomes a first-class engine contract.

Negative: scope turns cost real tokens on issues that may die at stage 3 (mechanical validation). The five-stage pipeline introduces ceremony that lightweight runs do not need.

Alternatives Considered

Five-stage governed pipeline (ingest, propose, validate, approve, freeze) with trust separation between proposer and optimizer (accepted)

Continue hand-authoring harnesses with manual validation (rejected)

ADR-0013: Turn result contract — structured state back from turn pods

Status: proposed | Date: 2026-07-06

References: RFC-0001

Context

Turn pods return everything via stdout markers scraped from kube logs. Six marker literals are duplicated across crates with "keep in sync" comments. 2026-07-05 production incidents exposed fragility: openshell event stream glued messages breaking parse contracts; payloads fight kubelet's 10 MiB log-rotation budget; binary artifacts ride logs as base64; markers duplicate across a crate DAG that forbids sharing. The OTLP collector was lost when vendored agentic-ci was dropped, and harness logic was trapped in a binary.

Decision

Two-tier result contract in a shared crucible-contract crate. Tier 1: verdict rides the Kubernetes termination message (4096 B, self-capped at 3584 B). Tier 2: artifacts POST to the controller's evidence drop-box via an authenticated endpoint (pod-bound projected SA token validated by TokenReview). Stdout demotes to a human-facing live/debug channel. The crucible-harness crate is extracted as the agentic-ci replacement; the OTLP collector is restored in-process.

Consequences

Positive: verdict path stops depending on log rotation, newline discipline, or base64-in-logs. Scope turns survive controller downtime. Artifact caps become honest HTTP limits. Six keep-in-sync literal pairs collapse into one typed crate (crucible-contract). Authoritative cost returns to the ledger. Harness becomes a consumable crate.

Negative: requires a controller endpoint and projected SA token infrastructure. Two transport tiers add operational complexity versus the single-channel stdout approach.

Alternatives Considered

Two-tier result contract: verdict in termination message (4096 B), artifacts via authenticated HTTP POST to controller evidence drop-box (accepted)

Continue using stdout markers scraped from kube logs for all result communication (rejected)

ADR-0014: Declarative image builds — build backends + the building state

Status: proposed | Date: 2026-07-08

References: ADR-0005, ADR-0008, ADR-0011

Context

Four tiers of image production exist. Only CI-built and in-loop candidate builds are automated. Hand-built images (sandboxes, loop-openshell) use mutable tags and hand-plumbed digests. Version-knob choreography (VLLM_VERSION, REPO_SHA, WORLD_BASE) is synchronized by convention, not tooling.

Decision

One BuildBackend contract with two implementations: Cluster (detached rootless-buildah Job) and GithubActions (workflow_dispatch + poll). Named builds are declared in the domain manifest as [build.]. Digests are resolved by crucible itself via the registry. watch.paths defines when a build is needed. The template vocabulary is closed (no string injection). The controller blocks measurement on an explicit "building" state. crucible build provides a CLI for human-invoked builds.

Consequences

Positive: onboarding domain images becomes a manifest edit. Digests flow mechanically from the registry. Measurement rig can never run on a stale or half-built image because the controller blocks measurement on the explicit "building" state.

Negative: two build backends to maintain. The template vocabulary is deliberately closed, which limits extensibility. The "building" state adds a new controller lifecycle phase.

Alternatives Considered

One BuildBackend contract with Cluster and GithubActions implementations; named builds in manifest [build.]; digest resolved via registry; controller blocks measurement on building state (accepted)

Generate workflow files on the fly per domain (rejected)

ADR-0015: The loop pod stops being a container host — OpenShell Kubernetes driver

Status: proposed | Date: 2026-07-08

References: ADR-0002, ADR-0005, ADR-0011, ADR-0014, RFC-0001

Context

The openshell backend runs the sandbox as a container nested inside the loop pod via podman. This forces privileged:true on the loop pod, requires podman/buildah/fuse-overlayfs in the loop image, needs KUBERNETES_SERVICE_* env scrubbing, and requires bespoke credential paths. OpenShell ships a Kubernetes driver that runs the sandbox as a sibling pod, already present in the fork commit.

Decision

Make the compute driver a configuration knob, defaulting in-cluster to the Kubernetes driver. The sandbox becomes a sibling pod instead of a nested container. Five changes: broker reach contract (transport-agnostic hostname resolution), NetworkPolicy update (sandbox reaches broker over real cluster networking), agent-sandbox CRD prerequisite, RBAC updates (ClusterRole for tokenreviews and nodes), and supervisor delivery via init-container. The podman driver stays supported for laptop and EC2 use.

Consequences

Positive: loop pod stops being a container host. Sandbox gets ordinary Kubernetes scheduling, limits, and GPU admission. The upstream patch retires. Path to privileged:false opens (combined with declarative image builds).

Negative: agent-sandbox CRD serves v1alpha1 (deprecated); driver's README labels its schema "RFC 0005 POC." Sibling pod networking adds real cluster network hops versus localhost. RBAC surface grows (ClusterRole for tokenreviews, nodes).

Alternatives Considered

Make compute driver a configuration knob, default in-cluster to Kubernetes; sandbox becomes a sibling pod with broker reach over cluster networking (accepted)

Keep podman driver as the default, running sandbox as nested container inside the loop pod (rejected)

ADR-0016: Candidate build modes — how a proposal becomes a measured artifact

Status: proposed | Date: 2026-07-09

References: ADR-0001, ADR-0005, ADR-0008, ADR-0009, ADR-0014, ADR-0015, RFC-0001

Context

The step between "agent edited a file" and "judge read a score" is undocumented. Three modes exist in practice: no artifact (compile and run in place), no rebuild (config tuning only), and build and roll (derive-layer for Python, full image build for compiled). Two critical properties are unenforceable without naming the modes: compile errors should be free (not spend a candidate), and unchanged components should not rebuild.

Decision

Name the modes, declare per component, engine enforces preconditions. mode is a typed per-component manifest field (none/derive-layer/image). crucible check enforces preconditions (same-registry for derive-layer, no compiled sources in derive-layer paths, frozen Containerfile for image mode). The build recipe is part of the judge (Containerfile must be a frozen inject). Modes live in the contract, not only the ADR. Per-iteration builds stay on-cluster (dispatch latency rules out GH Actions for per-turn builds).

Consequences

Positive: mode becomes readable, checkable, and reviewable in the manifest. Silent 20-minute or no-op failure modes become crucible check errors. The reward-hacking hole in the build recipe closes (Containerfile must be a frozen inject). ADR-0015's P5 gains its precondition.

Negative: mode is a new per-component field every domain must declare. crucible check's precondition enforcement adds validation complexity. The closed set of modes (none/derive-layer/image) may not cover future build strategies.

Alternatives Considered

Name the modes as a typed per-component manifest field (none/derive-layer/image), engine enforces preconditions via crucible check, build recipe is part of the frozen judge (accepted)

Leave build mode as implicit bash in apply hooks with no engine enforcement (rejected)

ADR-0017: Measure task DAGs — the engine walks the ladder

Status: proposed | Date: 2026-07-18

References: ADR-0001, ADR-0005, ADR-0013, ADR-0016, RFC-0001

Context

GPU-measured code domains grade through a sequence of oracles (CPU self-check, single-GPU diff, ncu capture, multi-GPU equality, compute-sanitizer). The sequence lives inside an opaque 800-line domain gate script. Three incidents exposed the cost: no per-step resume (run 13 repaid all proven steps after a pod restart), silent 8-hour Kubernetes retries on transient failures, and generic judge policy reimplemented independently per domain.

Decision

The manifest declares the measurement DAG as data ([[measure.task]]), and the engine walks it. Named job templates replace the fixed benchmark/lm_eval/profile trio. Tasks are connected by depends_on edges with a typed validity model (required vs advisory, substrate capabilities, hardware truncation computed before dispatch). TaskDagJudge sits behind the existing Judge trait. The walker is a ready-set loop; v1 pins max_inflight=1. Retry policy splits on classification: measured failure is never retried, transport failures get bounded auto-retry. Cache keys derive from session log fold. Amendment: a task's $OUT may carry explicit status (including skipped).

Consequences

Generic judge policy written once in Rust. The next codegen domain declares ~40 TOML lines instead of copying the 800-line walker. Per-task cache keys make regrade incremental. Single-shot domains keep measure_cmd unchanged. Cost: the DAG walker adds engine complexity and the typed validity model introduces new failure modes (hardware truncation, substrate mismatch).

Alternatives Considered

Engine-owned DAG walker with typed task validity model (accepted)

Broker-side orchestration: let the broker walk the measurement sequence (rejected)

  • Rejected because: Recreates the opaque single-call problem; the broker becomes the new 800-line script.

Gate script as thin grader: keep opaque script but thin it to a grading wrapper (rejected)

  • Rejected because: Preserves the copy-paste channel between domains; thin wrapper still duplicates judge policy.
  • Rejected because: Scope creep; the measurement ladder is specific enough that a narrow walker outperforms a generic framework.

ADR-0018: Recovery classification for --resume

Status: accepted | Date: 2026-08-04

References: ADR-0003, ADR-0004, RFC-0001

Context

Resume was a pure counter fold that discarded all information about how the run died. A shutdown line's absence (pod died mid-run) was never read. Dangling agent_start/done brackets were invisible. A pod that died while parked lost the approval entirely.

Decision

One streaming pass over the log produces both resume counters (ResumeFold) and a typed Classification of the tail. plan_recovery maps classification to RecoveryPlan (NoOp/Refuse/Continue). Classification is derived from the log tail, never markers. New wire events (approval_wait/approval_resolved) bracket every approval. Policy: finished/solved is a clean no-op; escalated refuses to resume; budget honors a raised cap.

Consequences

Old logs lacking the approval bracket degrade to died_between_iterations (preserving old behavior). Unknown shutdown.outcome or approval_wait.mode tokens degrade rather than error. The classifier encodes the emission grammar; tests live next to the scanner. Cost: new wire events must be emitted at every approval boundary.

Alternatives Considered

Streaming classifier over log tail with typed RecoveryPlan (accepted)

Marker-based recovery: write explicit resume markers at crash sites (rejected)

  • Rejected because: Markers are written before the crash, so a crash that prevents the marker write is invisible. Log-tail classification reads the emission grammar and degrades gracefully for old logs.

ADR-0019: Admission ledger for external inputs

Status: accepted | Date: 2026-08-04

References: ADR-0003, ADR-0018, RFC-0001

Context

Every external input was volatile, unrecorded, or both. Steer text was lost on pod death after read-then-blank. Approve/deny/rescope were in-memory mutex slots (double approve, silent overwrite, lost on pod death). Set-budget/pause reverted on resume. No idempotency keys existed.

Decision

Append-only NDJSON file (state/admissions.jsonl) with a two-state machine per idempotency key: exactly one "admitted", then at most one "settled". Adapted from flue's AgentSubmissionStore. Three rules: admission precedes effect (fsync before mutation), idempotency converges (same key+payload returns original), ledger is authoritative for operator inputs. Derived grant keys link approvals to asks. Settlement is keyed to turn completion. File mechanics live in forge::ndjson (flock-guarded append, torn-tail-tolerant fold).

Consequences

The control bridge no longer writes STEER.md. Two durable logs can disagree after a crash (one declared authoritative). Each admission costs an fsync. The file is never compacted.

Alternatives Considered

Append-only NDJSON ledger with two-state machine per idempotency key (accepted)

In-memory mutex slots with checkpoint snapshots to disk (rejected)

  • Rejected because: Snapshots are point-in-time and lose inputs between checkpoints. The append-only ledger records every admission with fsync-before-effect, so no input is lost on crash.

ADR-0020: Durable tool steps for broker builds and measures

Status: accepted | Date: 2026-08-04

References: ADR-0005, ADR-0017, ADR-0019

Context

Every completed piece of expensive broker work lived in memory and died with the pod. The codegen memo and built provenance set were in-memory mutexes. Broker restart forced rebuilds; a 90-minute GPU oracle was repaid in full after any restart.

Decision

A durable step is a unit of work whose completed result is an immutable value, recorded in an append-only NDJSON ledger keyed by content of inputs. Semantics: at-least-once-executed, exactly-once-recorded. Only settled facts are recorded (Ok is recorded, Err/transport is not; a deterministic measured failure IS a fact). Eligibility: values, never state (deploy_candidate is NOT a step). The ledger is a cache, never authoritative. Amends ADR-0017's cache section for the broker half.

Consequences

Replay covers broker restart within a live pod. Replayed replies carry cached:true. Mid-job pod death orphans the Kueue Job (at-least-once accepted for v1). A cached compile error could mask a fix outside tree+config (narrow exposure window).

Alternatives Considered

Durable step: immutable value in append-only NDJSON ledger keyed by input content (accepted)

In-memory cache with periodic checkpoint to disk (rejected)

  • Rejected because: Periodic checkpoints lose work between snapshots. The append-only ledger records each completed step immediately, so a restart replays from the full history.

ADR-0021: The no-judge task lane

Status: accepted | Date: 2026-08-17

References: ADR-0001, ADR-0004, ADR-0018, RFC-0001, RFC-0002

Context

Crucible had no lane for unsupervised agent chores with no objective ("consolidate dependabot PRs", "fix flaky tests nightly"). [judge] was required at the type level. Teams reached for external runners that hand the agent credentials and keep no durable state.

Decision

A manifest with no [judge] table builds TaskJudge: measure emits Reading with valid:true, score:None; decide keeps unconditionally; improved is unconditionally true; objective() returns "task". Rows keep the ordinary decision:"keep" shape with score:null. skip_baseline is forced on. Composites still require [judge]. A task manifest rejects [search], [workflow], and [preflight]. crucible check skips measure probe, editable-gate lint, and selftest, but requires a non-empty goal and prints a loud notice.

Consequences

Trust boundary untouched. Exit 0 means "run completed" not "chore succeeded". Summary.best_score and Segment.baseline_score became Option on the wire. Downstream dashboards reading decision=="keep" as "beat objective" must branch on gate=="task". RFC-0002 later reopened two of the restrictions above: a judgeless manifest now accepts [workflow] when its type is "playbook", and a judgeless composite is permitted without [judge] and produces no combined score. Both were closed here because a lane that keeps unconditionally had nothing to say about a graph; the playbook lane gives it a verdict that is not a score, which is what the restrictions were standing in for. The rest of the decision holds, and exit 0 means something stronger for a playbook than it does here.

Alternatives Considered

Absent [judge] = the task lane, with TaskJudge that keeps unconditionally (accepted)

[judge] mode = none: add a mode field to the existing judge config (rejected)

  • Rejected because: Adds a concept, makes every JudgeCfg field conditionally required.

[agent] mode = task: put the run semantics on the agent section (rejected)

  • Rejected because: Conflates proposal-policy with run semantics.

Separate one-shot subcommand for unjudged work (rejected)

  • Rejected because: Re-implements session Start/Row/Summary/Shutdown/publish/resume from scratch.

ADR-0022: Measurement sessions — one warm engine, many observations

Status: proposed | Date: 2026-08-14

References: ADR-0005, ADR-0006, ADR-0008, ADR-0017, ADR-0020

Context

Every brokered measure call boots the engine from cold, measures, and dies. GLM-5.2 NVFP4 revalidation measured ~25 min per rung, of which ~9 min is actual measurement. Four GPU jobs per iteration pay that cold-boot toll four times.

Decision

A measurement session is a broker-owned engine process serving ordered measurement points against one candidate digest. Independence contract: declared resets between points, declared residue (accepted drift), bounded drift (max point count per session), fixed order, failed reset is not a measurement. Sessions are state (never a durable step), but points remain durable steps (identity includes reset-protocol digest and ordinal). A/B legs driven by import-time toggles stay one-shot jobs. Domains that declare nothing keep today's behavior.

Consequences

Comparability breaks at the switch (session-era numbers are not comparable to pre-session). Mechanism rung becomes a point inside the session. Expected saving: ~50 min per iteration. A new way to be wrong: dirty sessions (mitigated by reset record in each point's result).

Alternatives Considered

Broker-owned measurement session serving ordered points against one candidate digest (accepted)

Fat single job: combine all measurement points into one large job (rejected)

  • Rejected because: Loses per-point isolation, retry, and memo. Accepted as a prototype to validate the independence contract, but rejected as the end state.

Standing server outside the run: long-lived measurement process shared across runs (rejected)

  • Rejected because: ADR-0008's frozen world requires the candidate's own build; a standing server outside the run cannot serve a candidate-specific engine.

ADR-0023: Concurrent dispatch: completion-order rows and abandonable workers

Status: accepted | Date: 2026-08-22

References: RFC-0002, ADR-0021

Context

Both playbook obligations that concern concurrent dispatch run into the same executor code, and both were left undesignable by the implementation review until the tradeoff was settled.

Row liveness. RFC-0002:C-PLAYBOOK-LANE requires that rows be written as tasks settle, so a reader tailing the log sees progress live, and states that row order is therefore completion order. execute does the opposite on purpose: it collects a whole batch from run_many and records the results in declaration order so the scored lane's event stream is reproducible run to run. For the litmus pack's three isolated auditors, all three rows land at once in declaration order however the reporter behaves.

Termination. RFC-0002:C-PLAYBOOK-BUDGET requires terminating every attempt in flight when the wall-clock ceiling is reached. Clamping each task's deadline to the run ceiling and killing the process group discharges that for a child tree that responds to signals. It does not for a grandchild that calls setsid, which escapes killpg, or for a process in uninterruptible sleep, which ignores SIGKILL. Either wedges the calling thread's stdout pump, and both concurrent runners use std::thread::scope, where a worker cannot be abandoned. execute then never returns, the ceiling is never observed, and nothing reports at all. This hang is reachable on the scored lane today and is worse than anything the deadline work fixes.

Decision

Row ordering becomes a property of the lane, not of the executor. run_many hands results back as they complete, and the executor records them in that order only when the caller asks for it; the scored lane does not ask and keeps declaration order. Live progress is most of what a scheduled chore's log is for, and a reproducible event stream is most of what a scored run's log is for, so neither lane gives up the property it depends on.

Batch workers become 'static so run_many can join with a bounded wait, settle a wedged item as a failure, and abandon its thread. An abandoned worker must never touch run state again: worktree removal moves off the worker onto the run, or onto a later sweep. This closes a hang that exists on the scored lane now, which is why it is worth the concurrency risk rather than being deferred with the rest of the playbook work.

Consequences

Positive: the playbook's log becomes tailable, which is the difference between watching a scheduled sweep and waiting for it. The scored lane's determinism is untouched, so nothing that pins its row order moves. A run can no longer be wedged forever by one unkillable child, on either lane.

Negative: two batch orderings exist where there was one, and a test that reads rows must know which lane it is on. Abandoned threads are a new class of leak: a worker outliving its run holds a worktree and file handles, and the ownership rule that keeps it away from run state is a convention the type system does not enforce. Bounded-wait joins introduce a second timeout to tune beside the task deadline and the run ceiling.

The abandonment path must land with its worktree-ownership change in one commit. Split, it leaks a worktree per wedged task with nothing to collect it.

Alternatives Considered

Per-lane row ordering, and abandonable batch workers whose worktrees the run owns (accepted)

Move both lanes to completion order: one batch path, no flag (rejected)

  • Rejected because: Costs the scored lane a determinism its event stream guarantees today, to fix a problem only the playbook has.

Weaken C-PLAYBOOK-LANE to drop the completion-order promise, and record the wedge as a known hang (rejected)

  • Rejected because: Gives up live progress on exactly the fan-out where a reader most wants it, and leaves a reachable hang undocumented in code.

Terminate the engine process when the ceiling falls, and let the pod restart (rejected)

  • Rejected because: Loses the shutdown event and the reporting C-PLAYBOOK-LANE requires; a run that cannot say why it ended is worse than one that ends late.

ADR-0027: Playbook launch surface: registry, forms, schedules, one-shots, dedupe state

Status: superseded | Date: 2026-08-22 Superseded by: ADR-0033

References: ADR-0045, ADR-0044, ADR-0049, ADR-0041

Context

The playbook lane landed in core: a workflow that runs its graph once and produces no score, declared parameters compiled to a JSON Schema (crucible plan params), fan-out instances keyed by item, and bounded compilation that turns any hostile source into a file:line:col compile error instead of a dead process. The controller can launch work pods and owns Postgres, the bors-style queue, and the SPA, but has no path from "a pack exists in a git repo" to "a person can run it, on a schedule, without reprocessing inputs". Related: ADR-0045 (InputKind is the extension point for new work sources), ADR-0044 (the ledger the launch rows join), ADR-0049 (leader election; any sweep this ADR adds must fire exactly once across replicas), ADR-0041 (the PR-push machinery authoring reuses).

Decision drivers: the engine already emits everything a UI needs (schema, mermaid graph, compile diagnostics), so the question is ownership, not rendering. The queue's frozen rule is that a dequeued key carries no payload. Ceilings are the launcher's; a pack may not declare them. The engine compiles the graph; the controller must never interpret or subtract from a pack's inputs. Packs live in git; the controller holds pins.

Decision

Six surfaces, all thin layers over one launch path. The schema-driven direction wins because the engine already emits the schema, the graph, and precise diagnostics, so every alternative that moves authoring or interpretation into the controller duplicates an authority the pack already holds.

Registry. A registered playbook is a git-pinned pack: repo, ref, path, rev, tarball, plus a params_schema JSONB extracted by running the pinned engine's plan params at registration and on every pin bump, stored with a digest of the schema so a pin bump that changes the form is detectable before it is accepted. Extraction failure fails registration with the engine's error; nothing half-registers.

Launch path. InputKind::Playbook, key playbook:{pack}:{uuidv7}. Param values and launcher ceilings are stored on a playbook_launches row at adopt time and re-read by key at dispatch (the ADR-0045 Jira precedent), rendered into the work pod as repeated --param NAME=VALUE plus --max-cost/--max-time. The POST endpoint validates values server-side against the stored schema; the SPA form is convenience, the endpoint is enforcement.

Forms. The SPA renders the launch form from the stored schema with a hand-rolled component over the closed subset (string, pattern, default, doc, required). Launcher ceilings render as a visually separate section, bounded by admin caps.

Schedules. A schedules table; the daemon sweep claims due rows with a single UPDATE ... WHERE next_due_at <= now() AND enabled RETURNING, recomputes next_due_at, and inserts an ordinary launch row, so a scheduled run is indistinguishable from a manual one downstream. Missed windows are skipped and recorded; consecutive firing failures auto-disable at a threshold that is deployment configuration. Next-firing previews are computed server-side so timezone interpretation has one owner.

One-shots. Their own surface: frozen param snapshots, relaunch-by-prefill, and run-once-at as a deferred one-shot (fire_at, fires once, completes). A one-shot does not advance dedupe state unless opted in at launch.

Dedupe state. Controller-side, never in the graph. v1 is a cursor: schedule config maps a field of the run result to a param of the next firing, stored only after a successful run. v2 is a seen-set keyed by (pack, input scope derived from identity params), recording only passing fan-out instances, item keys sharing the adopted-issue vocabulary, fed back as a launcher-materialized workspace file a pack may read.

Import and authoring ride the same registry: import fetches a git URL, compiles server-side, and shows a preview gate (form, graph, diagnostics) before pinning; authoring edits controller-stored draft packs with a compile-on-save loop and graduates them to git as a PR over the ADR-0041 push machinery, after which the draft retires. Core's bounded compilation is what makes compiling arbitrary fetched or typed sources server-side acceptable.

Consequences

  • One launch path means every consumer (form, cron, one-shot, import test-fire, future asks) exercises the same validation and ledger rows; there is no second, weaker way to start a run.
  • The controller stays outside the graph: it never subtracts inputs, never rewrites a pack, and dedupe degrades gracefully — a pack that ignores the cursor or seen-set behaves exactly as today.
  • Costs: a new engine-subprocess dependency at registration time (mitigated by extracting once and storing the schema), a schema-subset coupling between the SPA renderer and the engine's params vocabulary (mitigated by the stored digest and the preview gate on pin bumps), and draft packs create a second place pack source can live (mitigated by graduation-and-retire, drafts never being schedulable targets of record without registration).
  • The seen-set's run-report ingestion path is new infrastructure; the cursor deliberately needs none, which is why it ships first.
  • Cron parsing adds a dependency.

Alternatives Considered

Schema-driven launch surface: the pack declares params, the controller extracts, stores, renders, and enforces; schedules, one-shots, and dedupe are thin layers over one launch path (accepted)

Controller-authored forms: form definitions live in controller tables, decoupled from the pack (rejected)

  • Rejected because: Forks the source of truth: the pack's params block already is the form definition and one declaration must serve CLI validation, ask validation, and the launch form; a controller-side copy drifts on every pin bump.

Engine-side persistent state for dedupe: the pack carries its own seen-set across runs via durable workspace or git memory (rejected)

  • Rejected because: A pack run is one pass over its inputs; what has already been processed is scheduling state, and the engine has no durable cross-run home for it (state rides an emptyDir). Putting it engine-side couples runs to storage the launcher already owns.

Controller subtracts seen inputs from the graph's fan-out before dispatch (rejected)

  • Rejected because: Puts the controller inside the graph, violating the engine-compiles-the-graph boundary; the controller supplies state, the pack subtracts.

Cron as degenerate one-shots, one surface for both (rejected)

  • Rejected because: One-shots are the lane's native shape and carry different semantics: frozen snapshots, relaunch-by-prefill, no dedupe advancement by default; folding them into cron rows makes both surfaces worse.

Kubernetes CronJobs or per-schedule timers instead of an in-controller sweep (rejected)

  • Rejected because: A CronJob fires a pod, not a launch row, so scheduled runs would bypass the one launch path (validation, ledger, dedupe) or need a shim that reimplements it; per-schedule timers scatter timezone interpretation and firing state across processes. The sweep keeps one owner for both, and ADR-0049 leader election already provides its single-writer guarantee.

ADR-0028: Secrets registry: user- and team-owned secrets, Vault custody through the hub, pull-with-grant projection

Status: accepted | Date: 2026-08-23

References: ADR-0046, ADR-0048, ADR-0049, ADR-0027, ADR-0044, ADR-0029, ADR-0030

Context

Packs need credentials: a write token for the dependency-sweep merge gate, clone tokens for private repos, API keys for skills, Vertex ADC for the agent, registry authfiles for candidate pushes. Today every one of them is a Kubernetes Secret an admin creates by hand in the loop namespace of every cluster, named from the deploy profile ([[secret_env]], [secrets].pull_authfile, pull_secret). Nothing records who owns a secret, who may use it, or when it was used; adding a cluster means copying every secret; and a person who wants to run a net-new workflow on the shared control plane has to file a ticket for an admin to mint and place credentials. The goal is self-service: a user registers a secret in the controller UI, owned by them or by one of their teams, binds it to a repo, playbook, or domain, and launches.

Current state. The SSO edge asserts the caller's idp (LDAP) groups as an OIDC claim, forwarded as a per-request header; the controller parses it (Groups) and matches /groups/team-x path-shaped groups for the operator role. Groups are not stored on the session. On the static-API-token path no identity header is stripped, and on the cluster-token path only user and email are stripped, so groups are caller-asserted on both non-proxy paths. Sessions are server-side in Postgres. ADR-0048 made the hub-to-spoke identity credential-free (projected SA token federation) and put the ingest drop-box on the external router shard behind the WAF, TokenReview, and a bound-pod claim; the claim checks the pod name only, pod names are deterministic per run, and the loop service account holds pods create in the loop namespace. Run pods are excluded from the pod timeout sweep. ADR-0046 deferred token rotation to Vault. The broker-token hardening work established the agent-visibility pattern for header-shaped bearers: the sandbox holds a placeholder and the egress proxy injects the real value. ADR-0027 fixed the rule that launch rows, params, and argv are echoed by the API and prefilled into relaunch forms, which is why a secret value must never travel any of those paths. Local mode spawns the engine with the controller's full environment.

Constraints. Corporate Vault is reachable from the hub and from the corp-internal spokes, but not from the GPU spokes (kelp, wharf, and the rest), so nothing on the spoke side may depend on Vault. The hub cannot reach the corp-internal spokes inbound; they receive no pods. the IdP group membership arrives only as a session claim; there is no directory lookup the controller can make offline, which matters for scheduled launches (ADR-0027 schedules fire with no session). Long-lived classic PATs are forbidden by org policy; the pack-PR credential is already a GitHub App installation token, and secrets of that kind stay chart-mounted controller identity. Kubelet image pull secrets are consumed before a pod runs, so they cannot be fetched by the pod. The manifest declared-secrets block, the loop-image redemption step, and the redactor are core-repo changes that ship with a loop pin bump before the controller release that enables this.

Decision

Three parts: an authorization model in Postgres, byte custody in Vault with the hub as the only client, and a pull-with-grant projection.

1. Model

A principal is user:<login> (the SSO login) or group:<group path>, stored normalized (trimmed, lowercased) and compared as exact full paths; the operator role's tail-segment match is not used for ownership. Both come from a controller-validated identity, never from a directory. A secret row carries a name, an owner principal, a kind (opaque, file, registry_authfile, kubeconfig), a visibility (broker_only or agent_visible), a Vault path, and a consumer class: run (projected into pods) or hub (read by the hub for its own clients, introduced by ADR-0030 for the kubeconfig kind). A binding attaches a secret to a scope (repo, playbook, domain) with a projection (an env name or a file path) and records the pack revision and schema digest it was made against. A grant is created per dispatch: the run, the secret ids it resolved, a redeem-by deadline, and the pod UID it is bound to; every redemption attempt is an audit row with outcome and error. An audit row records every register, rotate, bind, unbind, grant, hub read, and redemption with the acting principal (none for hub reads) and, for redemptions, the cluster.

Packs declare the secret names they need in the manifest, mirroring how params are declared; the manifest is the only declaration site, and a binding is the only mapping from a declared name to a value. The preview gate and import review show the declared names to the registering admin. A launch whose scope lacks a binding for a declared name is refused with the name in the error. The deploy profile's [[secret_env]] stays authoritative for a pack until its manifest declares secrets; when both exist for one name the preview gate warns and the binding wins. Kubelet pull secrets (pull_secret, and the measure-job pull secret) remain pre-created cluster Secrets named on the target; registry_authfile covers only in-pod pulls and pushes. Draft and import test-fires never resolve bindings. A pin bump that changes the pack revision parks launches on that scope until an owner member re-binds.

2. Authorization

Server-side on every route; the SPA is convenience. Any group in the caller's validated claims is ownable.

  • Register: any operator, as owner user:self or any group: in their claims.
  • Bind / unbind: a member of the secret's owner principal who also holds the global operator role.
  • Use at launch: the launcher's principals must cover the owner of every secret bound to the scope. One miss refuses the launch with the secret named.
  • Scheduled launch: the owner is the principal who last saved the schedule; a save by anyone else re-owns the row and re-runs the launch check against the saver's own claims. The save snapshots the owner's groups on the schedule row with a TTL. A schedule on a scope with bindings cannot be created or saved without an authenticated principal. At fire time, with ADR-0029 the hub refreshes the owner's groups from their offline credential; if the refresh fails the snapshot is used while inside its TTL, else the launch parks. Without ADR-0029 the snapshot alone applies. Existing rows have no snapshot and park until re-saved. A re-save expires that schedule's parked launches with a reason.
  • Reference: packs and MCP agents may name a secret. No human or agent may read one; there is no read-back endpoint. Admins see metadata and audit, not values.
  • Delete: owner member; refused while bindings exist.
  • Rotate: owner member; writes a new version. A grant resolves the current version at each redemption, so rotation reaches the next pod start; already-redeemed pods keep what they hold.

The global roles remain the outer check. There is no general per-resource ACL engine; ownership plus bindings covers every rule above, and a later rule such as "who may launch playbook X" reuses the principal and binding tables.

Prerequisite. Every rule above rests on Identity and Groups. They must come from a controller-validated source, which is ADR-0029. Until it ships, x-auth-request-groups and the identity headers are stripped on every non-proxy path, and group: owners are refused on the static-token path.

3. Custody

Vault KV v2, path crucible/registry/<principal>/<name>, versions from Vault. Amended 2026-08-29: a dedicated mount was not obtainable — the mount is an IT-managed standard this team does not control — so the registry shares the apps mount with the deploy-sync secrets and is namespaced under the same crucible/ prefix they use, one subtree the hub's policy can be scoped to. Registering at the mount's top level, as an unprefixed <principal>/<name> would, is refused by Vault. The hub's Vault login is a configured auth method behind one client: AppRole (role id chart-mounted, secret id in a pre-created Secret, wrapped response unwrapped at boot) is the method available at launch; JWT auth with the projected SA token replaces it once workload identity is confirmed on the Vault side, with jwt_validation_pubkeys (or jwks_ca_pem) plus bound_subject and bound_audiences pinned; the hub's discovery mirror is not an acceptable trust root for Vault because the mirror and the subject share a hostname. Switching methods is a chart value; the registry code sees one client. Postgres holds everything except the bytes.

Registration is synchronous: the UI POST writes the KV version and returns the id; plaintext lives in the hub for the request only. If Vault is unavailable the registration fails; nothing is queued. Two ways to bring a secret:

  • Value in the UI: the hub is the writer; the user never touches Vault. The self-service default.
  • Reference to an existing Vault path: the user registers vault://<mount>/<path>#<key> and the path's owner grants the hub's Vault identity read under their own policy; that grant is the path owner's consent to registry-governed use. Because the hub's policy may already cover paths other teams granted, registration and every re-point require a Vault token belonging to the registrant, with which the hub performs a one-shot verification read before storing the pointer; each bind requires the binder's own Vault token for the same read. The token is held in memory for the request only and is excluded from tracing, audit, and error bodies. Paths inside the crucible mount are refused as references. After registration, access is governed by the registry owner principal; a registrant who later loses Vault access keeps registry access until the path owner revokes the hub's read or an owner member unbinds.

The controller's own identity secrets (GitHub App key, fleet spoke kubeconfigs, database URL, push tokens, Jira token) are not user data and do not enter the registry. They are sourced from the same Vault mount under a deploy/<release>/ prefix and materialized into the chart's existingSecret objects by a controller vault-sync subcommand run as a chart hook Job and on a schedule, because the hub cluster has no secrets operator; the only chart-mounted secret left is the Vault login itself. Consumers (the controller env, kubelet pull, the spoke kubeconfig mounts) keep reading Kubernetes Secrets.

4. Projection

Dispatch resolves the scope's bindings, checks the launcher, creates the pod, records the pod UID from the create response on the pod row, and writes the grant bound to that UID with a redeem-by deadline; the pod spec carries the grant id and nothing else. A create that returns AlreadyExists voids the grant. On start, the loop image redeems the grant against the hub: it presents its own bound SA token; the hub TokenReviews it against the pod's cluster and requires the token's pod-uid claim to equal the recorded UID. Other identity modes are defined by the ADR that introduces the target kind. The hub reads Vault and returns the bundle. The same pod may redeem again after a restart; nothing else can. A re-drive that creates a new pod issues a new grant. The loop side writes env and files into a tmpfs and hands the loop process its environment, retrying redemption with capped backoff until the deadline.

Reading at redemption means plaintext never rests on the hub; a rotated secret is picked up by the next pod start; Vault availability is a dependency at pod start, not a cache to invalidate. The redemption endpoint sits on the external router shard with its own Route entry, its own WAF rate limit, and reencrypt termination, so a crash-looping redeemer cannot exhaust the ingest limit and the bundle does not cross the router-to-pod hop in plaintext. The chart change ships before the image. Redemption failures are recorded on the grant; reconcile parks the run and deletes the pod after N failed redemptions or when the deadline passes unredeemed, so a pod that cannot redeem does not hold a GPU in CrashLoopBackOff. Spokes never contact Vault and never hold a Vault token. No secret write RBAC is needed on any spoke. A dedicated turn service account with no RBAC in the loop namespace is a prerequisite, so that sandbox pods cannot create a same-named pod and redeem.

Values never appear in the pod spec, argv, ConfigMaps, launch rows, CRUCIBLE_INPUTS, transcripts, or events. The engine gains a redactor; the loop-side fetcher registers every redeemed value, every leaf value of a parsed file kind, and their base64 and percent-encoded forms with it before the loop starts.

5. Visibility

broker_only is the default and is defined per kind. Header-shaped opaque values get placeholder substitution at the egress proxy, following the broker-token precedent. file, registry_authfile, and kubeconfig kinds are broker-side only and refuse agent_visible. Vertex ADC for the agent goes through the gateway's google-cloud provider, not a placeholder. agent_visible is an explicit opt-in for opaque kinds that the UI warns about, because an agent-visible value can end up in a PR.

6. Local mode

No registry. Local runs spawn with a cleared environment and receive only PATH, the run's own CRUCIBLE_* variables, and the declared secret names resolved from an operator-controlled allowlist; the registration API refuses in local mode.

7. Sequencing

ADR-0029 guard changes first (or the interim header stripping above). Then the core release carrying the manifest block, the redemption step, and the redactor, with a loop and turn-profile pin bump. Then the controller release with the tables, routes, and UI.

Consequences

  • Register in the UI, bind on the repo or playbook page, launch; no ticket, no per-cluster copying. A binding on a scope resolves on any cluster the scope's runs land on.
  • The hub is a custodian, not an end-to-end encryptor. It decrypts (reads Vault) to project. A hub compromise reaches every secret the hub's Vault policy can read; the policy is scoped to the crucible mount plus explicitly granted reference paths, Vault audits every read, and path owners can revoke a referenced path themselves.
  • Vault and the hub become dependencies of pod start for any run that uses a secret. Profiles that already ingest through the hub gain nothing new; profiles that relied only on the end-of-run session dump now have a hard dependency at start, and a pod that cannot redeem is parked by reconcile with the recorded error.
  • Scheduled launches under team secrets are only as fresh as the owner's groups; the schedule-row snapshot plus TTL is a new obligation with a staleness window equal to the TTL; ADR-0029 narrows it to the refresh-token lifetime.
  • Reference-mode secrets add an operator-level risk: a registrant who can read a path can expose it to a run. The verification read with the registrant's own Vault token, the per-bind re-read, and the refusal of in-mount paths limit that to paths the registrant and binder could already read; loss of the registrant's Vault access is not detected until the path owner revokes.
  • Grants bound to a recorded pod UID and a no-RBAC turn service account close the same-name pod substitution that the name-only bound-pod claim allowed; the ingest drop-box gains the same UID check.
  • The engine gains a redemption step in the loop image, a declared-secrets block in the manifest, and a redactor; the preview gate gains a secrets section; the profile [[secret_env]] path stays for undeclared packs.
  • Redemption adds a fourth externally exposed path with its own Route and rate limit.
  • Two Vault-side asks precede implementation: a crucible KV mount with an AppRole bound to the hub, and, when workload identity is available, a JWT auth role bound to the hub's service account subject and audience with pinned validation keys for the hub's issuer. Amended 2026-08-29: the dedicated mount was never obtainable, and the policies as granted are inverted against this decision: the human OIDC identity (app-crud-crucible) holds full CRUD on apps/data/crucible/*, while the crucible AppRole the hub logs in with holds only list, read. "The hub is the writer" cannot hold while the writer is the human, which is why the config data was seeded by hand and why the first registration failed. The standing ask is to give the AppRole the registry subtree only, keeping crucible/deploy/* read-only for it, and noting that KV v2 splits the verbs across three API paths so a grant on data/ alone leaves delete broken:
path "apps/data/crucible/registry/*"     { capabilities = ["create", "read", "update"] }
path "apps/delete/crucible/registry/*"   { capabilities = ["update"] }
path "apps/metadata/crucible/registry/*" { capabilities = ["delete", "list", "read"] }

Neither identity can grant this: sys/policies/acl/* and auth/approle/role/crucible are both denied, so it is an IT request.

  • Deployment secrets gain a rotation path (edit in Vault, next sync applies) and a new failure mode: a sync that cannot reach Vault leaves the last materialized Secret in place, so boot never depends on Vault; only registry reads and redemption do.
  • ADR-0030 builds on the kubeconfig kind, the hub consumer class, and the grant path for user-registered cluster targets.

Alternatives Considered

Ownership plus bindings in Postgres, bytes in Vault with the hub as the only Vault client, per-dispatch grants bound to the pod UID and redeemed by the pod against the hub (accepted)

Envelope-encrypted values in Postgres with a chart-mounted KEK, the controller as sole custodian (rejected)

  • Rejected because: Vault is reachable from the hub, so this trades native audit, versioning, and user-side revocation for a KEK whose loss kills every registered secret and a blast radius of KEK plus database equals everything. Reconsider only for a deployment without Vault.

Keep pre-created Kubernetes Secrets per cluster, add ownership metadata on top (rejected)

  • Rejected because: Still copied per cluster and minted by admins; the metadata would describe secrets the controller cannot create, rotate, or place, so registration is not self-service.

Controller writes a per-run Kubernetes Secret onto the target cluster at dispatch (rejected)

  • Rejected because: Needs secrets create/delete for the federated identity on every spoke, widening the read-mostly grant of ADR-0048, and puts plaintext in spoke etcd; the pull model only needs the pod to reach the hub.

Vault Agent injector or CSI driver on each spoke (rejected)

  • Rejected because: The GPU spokes cannot reach Vault, so this would split projection behavior by cluster and break the property that a loop is dispatchable anywhere the hub can dispatch.

End-to-end encryption to the target cluster's key at registration (sealed-secrets style) (rejected)

  • Rejected because: Pins a secret to one cluster at registration; a loop that may land on several clusters would need the value re-registered per target, which is the per-cluster copying this decision removes.

A general per-resource RBAC engine over repos, playbooks, and loops (rejected)

  • Rejected because: A secret has one owner and a set of bindings; those two relations answer every rule in section 2. A generic ACL table would exist for rules nobody has.

A Keycloak service account with group-query rights for offline membership checks (rejected)

  • Rejected because: Groups are already a session claim, and native OIDC (ADR-0029) gives the hub a refresh token to re-fetch them for scheduled launches; a second credential and a directory integration would duplicate that.

Grants bound to the pod name, as the ingest drop-box binds today (rejected)

  • Rejected because: Pod names are deterministic per run and the loop service account can create pods in the loop namespace, so a same-named pod created before or after the hub's pod could redeem another run's grant. The UID from the create response is the only identity the hub itself observed.

ADR-0029: Native OIDC in the controller, replacing oauth2-proxy

Status: accepted | Date: 2026-08-23

References: ADR-0048, ADR-0049, ADR-0028, ADR-0030

Context

The controller sits behind an oauth2-proxy sidecar. The proxy runs the OIDC flow against Keycloak and the controller trusts identity from request headers: X-Auth-Request-User (preferred_username) and X-Auth-Request-Email from the checked-in chart, and X-Auth-Request-Groups (the IdP groups claim) from live proxy configuration the chart does not reproduce. The browser path passes the controller's bearer guard only because the proxy injects the static CONTROLLER_API_TOKEN as the Authorization header on every proxied request; the guard is layered outside the session layer, so it runs before any session is loaded. Server-side sessions already live in Postgres with a sliding 7-day expiry; the session identity source is a closed enum with one variant (proxy), and a source mismatch flushes the session. Two non-browser paths exist beside the proxy: the static API token presented directly, and a cluster-token bearer resolved through the own-cluster users/~ endpoint, which yields an OpenShift username that need not equal the SSO login. The cluster-token path strips the user and email headers but not groups; the static-token path strips nothing, so a holder of the API token on the direct route can assert any login and any group. The static token is also the documented break-glass and CD path, and an unset token forces a loopback bind. The CLI path through the proxy uses --extra-jwt-issuers so a Keycloak access token can be presented as a bearer; the chart warns that naming the web client there would accept foreign tokens. The chart's default provider is GitHub OAuth with an org and team allowlist; the hub runs on OIDC. The SPA hardcodes /oauth2/start and /oauth2/auth. Schedules fire from a sweep that claims rows FOR UPDATE and counts every failure toward auto-disable; leader election (ADR-0049 section 1) is not yet implemented.

Four problems. The controller only ever sees the claims the proxy forwards, so every new claim is proxy configuration, and the groups header already lives outside the chart. The header trust boundary is a hazard; the static-token pass-through is one instance. The controller never holds a refresh token, which ADR-0028 needs: a scheduled launch fires with no session, and re-fetching the owner's live IdP groups at fire time requires the controller to be the OIDC client. And the DB-sessions work already named hand-rolled OIDC, token exchange for SSO pass-through to remote clusters, and GitHub fork linking as the roadmap; ADR-0030 wants token exchange later for launching as the user on a cluster the user brought.

The integration is standard: authorization code with PKCE, JWKS fetch and cache, ID token validation, a session cookie the controller already knows how to issue, and JWT bearer validation for CLI callers.

Decision

The controller becomes the OIDC relying party. The oauth2-proxy sidecar, its route configuration, and the GitHub provider mode are removed after a one-release overlap.

Browser flow. Authorization code with PKCE and a state nonce, on controller-owned routes: a login route that starts the flow, a callback that completes it, a logout route, and the existing whoami route as the session probe, which also reports the auth mode. The web client requests the offline_access scope. On callback the controller validates the ID token against the issuer's cached JWKS (issuer, audience, expiry, nonce), creates a session holding sub, login, email, and groups, and sets the session cookie. Logout deletes the session and calls the issuer's end_session_endpoint; RP-initiated logout revokes session-bound tokens, which is why the offline credential below must be an offline token.

Users. A users table maps sub to the SSO login. A user: principal in ADR-0028 is the SSO login; schedules and secrets store the owner's sub. A cluster-token identity resolves to a users row by login or cannot own secrets or schedules.

Offline credential. The offline token is not session state. It lives in a per-user credentials table keyed by sub, one row per user, latest login wins, encrypted with a chart-mounted key, independent of session lifetime. Browser logout leaves it in place, so a user's schedules keep running after they close the tab. A user revokes it explicitly from their own settings, which parks their schedules that need team secrets until they sign in again. The Keycloak client is configured to permit refresh-token reuse (rotation revocation off, or reuse-max at least one), and each refresh persists the returned token inside the same transaction under pg_advisory_xact_lock keyed by sub, so two schedules of one owner firing in the same sweep cannot race a rotating token. A persist failure marks the schedule as needing sign-in. The relying-party client is restricted to a minimal scope and is not permitted token exchange.

Guard and extractors. The session layer moves inside the guard so a request may authenticate by session cookie or by bearer. Non-GET requests authenticated by cookie require a same-origin Sec-Fetch-Site or Origin check, because the cookie is SameSite=Lax and every tenant on the cluster's apps wildcard is same-site. Identity and Groups keep their shape and are populated from the session or the validated token; every route, role check, and guard that consumes them is unchanged. A native session identity source is added; flipping back to proxy mode flushes native sessions and forces re-login. Client-supplied X-Auth-Request-* headers are ignored in native mode on every path. Session groups are refreshed from the offline credential at most every N minutes on use; a failed refresh downgrades the session to viewer until re-login.

Bearer paths. Three: the static API token, which maps to a fixed configured identity and role (the admin break-glass and CD path) instead of carrying caller-asserted headers, and whose unset state means loopback-only when no issuer is configured either; the cluster-token path resolved through users/~; and a Keycloak access token presented as a bearer, validated by the controller against the same JWKS, replacing --extra-jwt-issuers. The JWT path requires typ=Bearer, azp equal to the public device-flow client, and rejects ID tokens and any token whose azp is the relying-party client. The device-flow client's scope must include the audience and groups mappers. Identity and groups on the JWT path come from the token's claims.

Group refresh for scheduled launches. Before the sweep claims a due row, outside the claim transaction, the hub refreshes the owner's ID token from the offline credential and reads the groups. Issuer-unreachable is transient: no failure count, retry next tick. A definitive refresh failure (revoked, expired) falls back to the schedule-row snapshot of ADR-0028 while inside its TTL, else parks the launch and marks the schedule as needing sign-in. The schedules view shows failures, disabled state, and sign-in needed. ADR-0049 section 1 lands before fire-time refresh runs on more than one replica.

Unauthenticated routes stay where they are. The ingest drop-box, the discovery mirror, and the grant redemption endpoint of ADR-0028 are outside human auth by design (ADR-0048) and are untouched.

Rollout. A chart value selects proxy or native for one release so production can flip and flip back. In proxy mode the controller's login route redirects to the proxy's start URL, callback and logout are no-ops, header trust stays on, and the SPA keeps /oauth2/* as its probe; in native mode the SPA uses the controller routes. Deployments on the GitHub provider must move to an OIDC issuer before the removal release. The proxy mode, the sidecar, and the GitHub provider are deleted in the release after. The relying-party client is registered in Keycloak with the controller's callback URL and the offline_access scope.

Library. A maintained OIDC client crate rather than hand-rolled discovery and validation.

Consequences

  • The controller sees the whole ID token. New claims are code, not proxy configuration, and the groups header stops living outside the chart.
  • The header trust boundary disappears in native mode; identity is a cookie the controller minted or a JWT it validated. The static-token pass-through hazard closes because that token carries a fixed identity.
  • Scheduled launches under team secrets follow live group membership, bounded by the offline token's lifetime rather than the controller's session lifetime. Offline tokens are the long-lived kind; whether the realm grants offline_access to this client is an ask to the SSO owners, and without it the design degrades to the schedule-row snapshot.
  • A new class of exposure: the credentials table plus the client secret can mint live tokens for any user who has signed in. Mitigations: the table is encrypted with a chart-mounted key, the client's scope is minimal and token exchange is disabled on it, and users can revoke their own credential.
  • Session-held groups are refreshed on use rather than only at login, so the launch check of ADR-0028 runs against groups no older than the refresh interval.
  • GitHub-provider login is removed. The hub runs behind Keycloak and the GitHub org allowlist was the pre-SSO access model; GitHub identity returns later as fork linking on top of SSO identity, not as a login.
  • Token exchange for SSO pass-through and as-user dispatch on brought clusters (ADR-0030) becomes possible; it is not part of this decision.
  • One release of dual-mode chart configuration and SPA routes, then the oauth2-proxy templates, values, and route annotations are deleted. Flipping back to proxy mode forces every native session to re-login.
  • A new dependency.

Alternatives Considered

The controller is the OIDC relying party: PKCE code flow, JWKS validation, server-side sessions, a per-user encrypted offline credential for refresh, JWT bearer validation for CLI callers (accepted)

Keep oauth2-proxy and forward more claims as headers (rejected)

  • Rejected because: Still no refresh token for the controller, so scheduled launches cannot re-fetch groups; the header trust boundary and the static-token pass-through remain; every claim is proxy configuration.

Keep oauth2-proxy for browsers and add a Keycloak service account for group lookups (rejected)

  • Rejected because: Adds a second credential and a directory integration to work around not being the OIDC client, when being the client gives the same answer through the standard refresh grant.

Store the refresh token in the session row (rejected)

  • Rejected because: Sessions expire after idle, are swept, and are deleted on logout, so the token would be gone exactly when a scheduled launch needs it; two concurrent sessions would also leave which token undefined. Durable credentials belong in a table keyed by user.

Session-bound refresh tokens instead of offline tokens (rejected)

  • Rejected because: RP-initiated logout and the SSO idle timeout revoke them, so a schedule owner who logs out or stays away parks every schedule; the offline token is the only kind that survives both.

OpenShift OAuth (the cluster's oauth-server) as the identity provider (rejected)

  • Rejected because: Ties browser identity to one cluster's OAuth, does not carry the IdP groups claim in the shape the controller already consumes, and does not help remote or brought clusters.

A different auth proxy (Keycloak Gatekeeper successors, Pomerium, and the like) (rejected)

  • Rejected because: Same category as oauth2-proxy: identity by header, refresh token held by the proxy, claims filtered by proxy config.

ADR-0030: Bring-your-own cluster targets: user-registered namespaces as dispatch targets

Status: accepted | Date: 2026-08-23

References: ADR-0046, ADR-0048, ADR-0027, ADR-0028, ADR-0029

Context

A dispatch target today is three things coupled at deploy time. A credential: a kubeconfig mounted by chart values under a clusters directory, where directory presence is the registration and the client cache never evicts; for fleet spokes the kubeconfig points tokenFile at a projected, kubelet-rotated SA token that the spoke's authn webhook trusts through the hub's issuer (ADR-0048), so no long-lived token is stored. A profile: the controller has one deploy profile for every cluster it dispatches to, mounted into the controller pod as a ConfigMap and passed by path to the core render-turn command; per-cluster profile files exist only on the standalone crucible deploy render path. A selection: one global dispatch-cluster setting; ADR-0048 named multi-spoke configuration a non-goal. Work-pod rows record their cluster by name, and the ingest validator resolves a pod's cluster through that row.

The spoke side has a contract that is not checked anywhere: a namespace, a turn service account, RBAC for the hub identity (pods create/get/list/watch/delete, pods/log, configmaps, and tokenreviews create as a ClusterRole), sandbox-driver grants for sibling pods and the OpenShell operator when the sandbox driver is kubernetes, a Kueue LocalQueue when the domain runs GPU measure jobs, pull secrets, an artifacts PVC or object store, and outbound reach from pods to the hub's external route. Dispatch requires that the hub reach the target's API server; push-only spokes receive no pods. Startup reconcile and the sweeps walk pod rows sequentially and hold a row on any non-404 error.

Users want to point the shared control plane at a namespace they already own on a cluster the fleet does not manage, and run there. Every piece above is admin work, so this is not possible without a ticket per cluster, and issuer federation in particular is cluster-admin work on the target. The fleet spokes keep their current registration; this decision is about targets a user brings. It depends on ADR-0028 for credential custody, the hub consumer class, and the grant path, and on ADR-0029 only for a later as-user dispatch mode. Composing a profile per dispatch is a core render-turn change that ships with a pin bump before the controller release.

Decision

A brought target is a user-owned row backed by a kubeconfig secret in the registry; the spoke contract becomes a registration probe; pod identity is proven without cluster-scoped RBAC.

Registration. A cluster_targets row: name, owner principal (user or idp group, as in ADR-0028), a reference to a registry secret of kind kubeconfig and consumer class hub, namespace, turn service account, pull secret names, artifacts backend, ingest URL (defaulting to the hub's external route), optional Kueue queue and measure substrate, sandbox driver, pod-identity mode, and a state (probing, ready, failed, unreachable, credential_rejected, credential_unavailable). Brought targets are addressed as target:<uuid> in pod rows; fleet names stay bare, cluster resolution handles both, and a brought target named like a fleet spoke is refused. Fleet spokes keep chart-mounted credentials and are not rows a user can edit.

The credential is a long-lived Secret-backed service-account token, per ADR-0046. The kubeconfig kind is parsed at registration into a closed struct (server URL, inline CA data, inline bearer token); exec plugins, tokenFile, client certificate paths, and proxy-url are refused, and the hub constructs the kube client configuration itself rather than loading the file. The zero-stored-credentials rule of ADR-0048 applies to fleet spokes; a brought target's credential is the user's, stored under the user's ownership, and read by the hub as an audited hub read with no acting principal. The hub writes the token to a tmpfs token file on first use so the client's own re-read handles rotation; on an auth rejection from the target the hub re-reads Vault and rewrites the file once, and if still rejected marks the target credential_rejected. The client is never rebuilt. If Vault is unavailable when the hub needs the credential, the target is credential_unavailable and its rows are skipped until the next watch rescan, which bounds the blind window.

Pod identity without cluster-scoped RBAC. Ingest and grant redemption prove a pod's identity by TokenReview on the fleet, which needs a ClusterRole a namespace owner cannot grant. For brought targets the hub validates the pod's projected SA token offline instead: the token is a JWT carrying the pod name and UID claims, and the target's signing keys are readable at /openid/v1/jwks by any service account by default, so the hub fetches and caches them with the registered credential. Pod-identity mode is tokenreview for the fleet and jwks for brought targets; both require the token's pod UID to equal the UID recorded at create (ADR-0028 section 4), and the ingest validator is keyed by mode. On an unknown key id the hub refetches at most once per cooldown per target, keeps the previous key set through the overlap, and returns 401 without a further fetch if the key is still unknown. Offline validation cannot see a deleted pod until its token expires, so the projected token is issued with a short expiry.

Profile as data. The hub composes a profile per dispatch and passes it to render-turn, replacing the one mounted profile. Four buckets: per-target fields from the target row or the fleet file (namespace, service account, pull secrets, artifacts backend, ingest URL, queue, substrate, sandbox driver, optional image pins); per-domain fields from the registered pack or playbook (its declared secrets, env, and measure contract); hub-owned defaults (loop and supervisor images, pins); and free-form env with precedence target, then domain, then hub. Fleet spokes move to the composed render in this decision, with their current profile file as the per-target source.

Registration probe. The row is created in probing and becomes ready or failed; a failed probe deletes its canary and leaves the row failed with the failing item named. Checks run with the registered credential: the API is reachable from the hub, the namespace exists, SelfSubjectAccessReview passes for each verb the hub needs, SelfSubjectRulesReview is reported with a warning for verbs outside the contract, pull secrets are present, the JWKS endpoint is readable, a LocalQueue is present when a queue is declared, the Sandbox CRD is present when the sandbox driver is kubernetes, and a canary pod (validated in jwks mode against the probing row) reaches the hub's external route and posts to ingest.

Selection and authorization. The global dispatch cluster becomes the default. The playbook launch form (ADR-0027) lists the targets the launcher may use: fleet spokes by role, brought targets by ownership. A target may be bound to a playbook or repo with the same binding shape as secrets, and a launch onto a brought target requires the launcher's principals to cover the target's owner. Because whoever administers the target namespace can read anything projected into its pods, a launch onto a brought target may only use secrets whose owner principal is in the launcher's principal set or equals the target's owner principal, and the launch form says so.

Failure isolation. A target is marked unreachable after N consecutive connection failures; startup reconcile and every sweep skip rows on targets in that state and time-box each target, so a dead brought target parks its own runs and never stalls a pass. A rejected credential is reported on the target, not as a transient network error.

Limits. The target's API must be reachable from the hub, directly or through a fleet-configured proxy tier of ADR-0046; corp-internal namespaces the hub cannot reach are out of scope until a pull mode exists. The run-live raw-TCP relay is hub-only; brought targets get turn-live and SSE logs until portforward-over-API lands. GPU measure jobs require Kueue in the target namespace, and the kubernetes sandbox driver requires the OpenShell operator; the probe reports a namespace without them as agent-turns-only with nested podman. As-user dispatch (the hub acting with the user's own cluster identity through token exchange) is a later mode that depends on ADR-0029 and is not part of this decision.

Consequences

  • Self-service targets: a namespace owner registers a credential and a namespace, passes the probe, and launches, with no fleet admin involved and no chart change.
  • The hub holds many users' namespace credentials. A hub compromise reaches every brought namespace. Mitigations: credentials live in Vault under the owner's path and are revocable by the owner; the probe reports verbs beyond the contract at registration; the token rests only on tmpfs; the closed kubeconfig parser removes command execution and token exfiltration through a crafted file.
  • A brought target's namespace admins can read every secret projected into its pods and the pod's own service-account identity. The secret-owner rule at launch bounds that to secrets the launcher or the target owner already controls; it does not make a brought namespace a trust boundary the hub enforces.
  • One identity mechanism per target, keyed by mode. Offline JWKS validation is weaker than TokenReview only in revocation latency, bounded by the short token expiry; key rotation on the target is handled by the cooldown refetch with overlap.
  • Per-dispatch composed profiles replace the mounted ConfigMap for every target, fleet included; profile changes take effect on the next dispatch, and the core render-turn change plus pin bump precede the controller release.
  • Targets never contact Vault; they redeem grants against the hub, so clusters that cannot reach Vault work unchanged. The hub's ability to observe a brought target does depend on Vault at credential load, bounded by the watch rescan cadence.
  • Startup reconcile and the sweeps gain per-target state and time-boxing; a dead target isolates rather than stalls.
  • Brought targets enlarge the set of clusters whose tokens the hub validates on the ingest and redemption paths; the negative cache and WAF rate limit of ADR-0048 cover them, and JWKS refetch is throttled per target.
  • Multi-spoke selection, previously a non-goal, becomes a launch-time choice with a default.

Alternatives Considered

User-owned target rows backed by a closed-form kubeconfig secret in the registry, offline JWKS validation of the pod's projected token, a composed profile per dispatch, a two-phase registration probe, and launch-time target selection (accepted)

Issuer federation only, as with wharf: every target trusts the hub's issuer (rejected)

  • Rejected because: Requires an authn webhook or API-server OIDC configuration on the target, which is cluster-admin work; it stays the fleet path and is not something a namespace owner can do.

Registration through chart values and a mounted kubeconfig, as fleet spokes do today (rejected)

  • Rejected because: Registration by deploy is admin-only and per-release, which is the process this decision replaces.

TokenReview for brought targets, asking users to grant the ClusterRole (rejected)

  • Rejected because: A namespace owner cannot grant cluster-scoped RBAC; requiring it excludes the users this is for.

A hub-minted per-pod bearer placed in the pod spec instead of validating the projected token (rejected)

  • Rejected because: A second identity mechanism beside the projected token, with a value in the target's etcd and no kubelet rotation, when the projected token already carries the pod name and UID claims and its signing keys are readable by any service account.

Load the user's kubeconfig with the standard kube config loader (rejected)

  • Rejected because: The loader honors exec plugins, tokenFile, client certificate paths, and proxy-url, which lets a registrant run commands on the hub or make the hub send a fleet spoke's projected token to a server the registrant controls.

As-user dispatch now: the hub acts with the launcher's own cluster identity via token exchange

A spoke-side agent installed by the user that pulls work from the hub (rejected)

  • Rejected because: Spoke-side controller components were ruled out in ADR-0048; a pull agent also needs its own credential to the hub, its own release cadence, and reintroduces per-cluster behavior the probe-plus-client model avoids. It is the only route to corp-internal namespaces the hub cannot reach; those are out of scope.

ADR-0031: Deployment secret projection by the Vault Secrets Operator

Status: accepted | Date: 2026-08-25

References: ADR-0028

Context

ADR-0028 put the controller's own identity secrets (database URL, GitHub App key, push tokens, Jira and Datadog tokens, spoke kubeconfigs, registry pull secrets) in the same Vault mount as the registry under a deploy/<release>/ prefix, and projected them into the chart's existingSecret objects with a crucible-controller vault-sync subcommand run as a pre-install hook Job and an hourly CronJob. That projection was chosen on the stated premise that the hub cluster runs no secrets operator.

The premise was wrong. the hub has the Vault Secrets Operator installed: secrets.hashicorp.com/v1beta1 is registered with VaultConnection, VaultAuth, VaultStaticSecret, VaultDynamicSecret, VaultPKISecret, and SecretTransformation, and crucible--runtime-ext may create all three of the kinds this needs. Nothing checked for it, so the chart carried a sync of its own: a mapping ConfigMap, a hook Job, a CronJob, a ServiceAccount with namespace Secret RBAC, a subcommand, and roughly 1300 lines of module behind it.

Two costs of the hand-rolled path had already surfaced. The AppRole secret id has to be mounted into every pod that syncs, so the credential spreads with the schedule. And ADR-0028 left rolling the Deployment after a rotated env secret out of scope, so a rotated value sat in a Secret that the running controller never re-read; the hourly tick made the secret current and the process stale. Separately, every CronJob run since the chart landed had failed before reading a path, which is what prompted looking at the mechanism at all.

The projection is the only part of ADR-0028 at issue. Vault stays the custodian, the mount and the deploy/<release>/ prefix are unchanged, consumers keep reading Kubernetes Secrets, and the registry, the grant path, and the hub-as-only-Vault-client property are untouched.

Decision

Deployment secrets are projected by the Vault Secrets Operator. The chart renders one VaultStaticSecret per mapping entry over a shared VaultConnection and VaultAuth, and the mapping ConfigMap, hook Job, CronJob, ServiceAccount, RBAC, and the vault-sync subcommand are deleted.

It wins because the projection is a solved problem the cluster already solves: the operator owns the login, so the AppRole secret id stops being mounted into sync pods; it reconciles continuously rather than on a schedule; and rolloutRestartTargets restarts the controller Deployment when a synced Secret changes, which closes the rotation gap ADR-0028 deferred rather than adding another mechanism for it. It also removes a component from a chart whose failure mode was invisible until a run was inspected by hand.

The mapping's shape survives the move. destination.transformation.templates rewrites KV fields into the Secret keys the chart's existingSecret references expect, and the transformation excludes every source field by name so that a field nobody mapped stays out of the projected Secret, which is what the subcommand's explicit key mapping did. excludeRaw is set beside it but covers only the whole-payload blob.

The Vault login Secret and the registry pull secrets stay hand-created for their existing reasons: the login is what the operator authenticates with, and the kubelet pulls the controller image before any reconcile has run.

Consequences

The chart loses a component it maintained and a credential mount: roughly 2300 lines deleted against 240 added, and no pod of ours holds the AppRole secret id to sync with. A rotated deployment secret now reaches the running controller without a manual roll.

The hub cluster gains a hard dependency on the Vault Secrets Operator being installed. A cluster without it cannot deploy this chart with controller.vault.enabled, where before the chart carried its own sync; that trade is deliberate for a hub, and is the reason the decision is scoped to deployment secrets rather than to registry projection, which still reaches spokes the operator does not run on.

The operator reads fixed key names, the CA bundle under ca.crt and the AppRole secret id under id, while the controller's own Vault client reads whatever caKey and secretIdKey name. Both Secrets carry both spellings, which is a duplicated value in two keys and a step in the onboarding script that will be wrong if only one is rotated.

Sync failures move from a CronJob exit code and pod logs to the operator's own status on each VaultStaticSecret, so the runbook for "why has this path not landed" changes; and reconcile behavior, including what happens when Vault is unreachable, becomes the operator's to define rather than ours to guarantee.

Alternatives Considered

One VaultStaticSecret per mapping entry, projected by the cluster's Vault Secrets Operator (accepted)

Keep the chart's own vault-sync: fix the startup panic and add the pod-template hash annotation the rotation gap needs (rejected)

  • Rejected because: Rebuilds, on our maintenance budget, what the cluster already runs, and keeps the AppRole secret id mounted into every sync pod. The rotation follow-up it still owes is a feature the operator ships.

Vault Agent injector sidecar or the Secrets Store CSI driver on the controller pod (rejected)

  • Rejected because: Projects into the pod's filesystem, not into Kubernetes Secrets, so the kubelet still has no pull secret and the spoke kubeconfig mounts and env references would all have to change. ADR-0028 already rejected per-spoke injectors for the registry.

External Secrets Operator with a Vault SecretStore (rejected)

  • Rejected because: Equivalent in shape, but it is not what this cluster runs; installing a second secrets operator to avoid using the installed one inverts the reason for the change.

Keep both paths, with the operator objects behind a chart flag (rejected)

  • Rejected because: Two projections for one set of Secrets, each able to overwrite the other, and the hand-rolled one keeps its maintenance cost while being the path nobody exercises.

ADR-0032: Direct autoresearch pack launch reuses approved-scope lifecycle

Status: accepted | Date: 2026-08-26

References: ADR-0027

Context

Operators need to run an already-authored autoresearch pack on the hosted controller without paying for an LLM scope turn or asking an agent to reconstruct files that already exist. The controller already has a durable frozen-pack store and a mature scoped-to-approved-to-build-to-run lifecycle. ADR-0027 separately establishes a single downstream launch path for registered playbooks; the same duplication risk applies here.

Decision

Direct autoresearch launch will fetch and freeze the administrator-named repository, revision, and pack path, validate it before adoption, and enter the existing autoresearch state machine at the approved-scope boundary. The endpoint is an adapter into the existing lifecycle rather than a second runner.

Consequences

Direct launch avoids unnecessary model spend and preserves one implementation of build planning, pod admission, run tracking, and completion. The stored scope remains the immutable provenance link for the run. The initial adapter uses scenario-shaped issue provenance, which keeps the change small but may warrant a later generalized pack input kind if direct launches need distinct querying or policy.

Alternatives Considered

Enter at the approved-scope boundary and reuse the existing autoresearch lifecycle (accepted)

  • Pros: Reuses frozen pack storage, build planning, pod dispatch, run tracking, completion, and ledger behavior, The launch POST is a clear human authorization for the exact fetched tarball
  • Cons: Direct launches retain scenario-shaped provenance until input kinds are generalized

Create an independent direct-run state machine and dispatch path (rejected)

  • Pros: Could model direct-launch provenance with bespoke tables and statuses
  • Cons: Duplicates build, admission, dispatch, tracking, completion, and recovery logic

Register autoresearch packs as playbooks and use the playbook launch API (rejected)

  • Pros: Uses the existing no-scope API surface
  • Cons: Conflates iterative scored autoresearch with single-pass playbook semantics

ADR-0033: Two playbook lanes with policy-gated draft-head schedules

Status: accepted | Date: 2026-08-27

References: RFC-0001, ADR-0027

Context

The launch architecture in ADR-0027 deliberately kept recurring schedules on Git-pinned registry packs while drafts supported fast authoring and test fires. Agent-assisted authoring now makes the transition between those lanes common enough that operators want both a direct inspect-and-clone path and, in exceptional development deployments, a controlled way to let recurrence follow draft saves. This changes the earlier premises that packs live only in Git for durable operation, drafts always retire at graduation, and drafts can never be recurring targets. The default should retain the GitOps boundary while the exception remains explicit and reversible. RFC-0001 owns the resulting product contract.

Decision

Choose the two-lane, policy-gated design governed by RFC-0001. This decision retains ADR-0027's registry, shared launch path, schema validation and generated forms, launcher-owned ceilings, claimed schedule sweep, distinct one-shot surface, controller-owned dedupe, import and authoring surfaces, and controller/engine authority boundary. It replaces the assumptions that durable operation always originates in Git, that every draft retires at graduation, and that drafts can never be recurring targets. Git-adopted revisions remain the normal lane; following a draft head is an administrator-enabled development exception because that balances rapid agent iteration with reviewable production provenance better than either an absolute prohibition or unconditional mutable recurrence.

Consequences

The ordinary path keeps reviewable Git provenance and reproducible recurrence, while development operators can exercise a real recurring loop during agent-assisted authoring. The exception adds conceptual and implementation complexity around policy transitions and exact firing identity, but RFC-0001 makes that cost visible and bounded. Superseding ADR-0027 avoids leaving two contradictory playbook-launch decisions while preserving its unaffected architecture here.

Alternatives Considered

Keep recurring schedules registry-only without an exception (rejected)

  • Pros: Preserves the simplest and strongest GitOps boundary.
  • Cons: Forces development operators to emulate recurrence manually during rapid iteration.

Permit every draft to be scheduled unconditionally (rejected)

  • Pros: Makes iterative recurring workflow development frictionless.
  • Cons: Turns mutable controller state into the production default and obscures what future firings will execute.

Keep adopted revisions as the default and gate draft-head recurrence behind an audited admin policy (accepted)

  • Pros: Preserves Git-reviewed immutable recurrence by default while allowing an explicit development escape hatch.
  • Cons: Adds policy-transition handling and requires each mutable firing to snapshot an exact draft version.

ADR-0034: Typed failures at operator and agent boundaries

Status: proposed | Date: 2026-08-28

References: ADR-0044

Context

Failures that reach an operator or an agent are currently strings assembled by anyhow context, and they read like it. A live run parked with run errored: short-circuited at scan, which names where it stopped and not why; the actual cause, transport retries exhausted (3 attempts): agent spawn failed: failed to launch agent source: No such file or directory (os error 2), survived only in a task note the rendered table truncated. A separate park reason reached the same surface as the bare string insert_work_pod, which is the name of a function, because a .context("insert_work_pod") intended for a developer's backtrace became the whole of a user-facing field. Nothing in the type system distinguishes those two audiences, so neither case is a bug anyone could have caught at compile time.

The scale rules out a uniform answer: the controller carries 759 .context calls and 123 bail!/anyhow! sites. Most are internal plumbing where a backtrace is exactly the right product and no one is served by an enum. A minority cross a boundary into a durable, user-visible field: a park reason, a task note, a dispatch refusal, an API error body. Those are the ones with an audience.

The house pattern for the second kind already exists and is used well in places: TargetRefusal, ProbeFailure, AdmitFailure, ImportError, RegisterError and PutArtifactError are all thiserror enums with written messages, and thiserror is already a workspace dependency. The gap is that the boundary is a convention rather than a type, so a raw anyhow::Error reaches a user-facing field without resistance. DispatchCapability::refusal returns Option<String>, which is a refusal reason with no type at all, and several existing enums use #[error("{0}")] over an opaque inner string, which buys the shape of a typed error without its description.

Agents raise the stakes. An agent reads these fields as its only account of a failure, cannot open a terminal to investigate, and will act on whatever it is given. A reason that names a function rather than a cause is not merely unhelpful to it; it is misleading input.

Decision

A failure that crosses into a field a person or an agent reads will be a typed error with a written message. A failure that stays inside the process keeps anyhow, because a backtrace is the right product for a developer.

The boundary is the audience, not the layer. It covers the durable ledger fields a reader is given as the account of what happened: park reasons, task notes, dispatch refusals, and API error bodies. Reconciliation writes most of these far from any HTTP handler, so the type has to travel with the failure rather than be applied at the edge.

Each boundary type is a thiserror enum whose variants name causes rather than call sites, and whose messages are written for the reader who will act on them. A variant that says what to do next is doing its job; #[error("{0}")] over an opaque string is not, and does not count as typing the failure. Where a reportable failure is produced from internal plumbing, the conversion is explicit and names a cause: an anyhow::Error may not be laundered into a typed wrapper to satisfy the signature.

The existing enums are the reference, and DispatchCapability::refusal returning Option<String> is the shape being retired.

Consequences

Failures acquire an audience the compiler knows about. A raw context string can no longer reach a park reason without someone writing a variant for it, so the class of defect that produced insert_work_pod in an operator-facing field stops being reachable by accident rather than being caught in review. Each variant becomes a place to say what to do next, which is the part an agent needs and a backtrace never supplied. Because the boundary set is small next to 759 context sites, this lands incrementally against in-flight work.

The costs are a second idiom and a conversion. Two error styles now coexist, so the boundary has to stay legible or it degrades into folklore about which functions are "the typed ones"; the answer is that a type is required exactly where a failure becomes reportable, which is checkable in review. Conversion sites are where this decision can be defeated: wrapping an anyhow::Error in a transparent variant satisfies the signature while preserving the original problem, and the existing #[error("{0}")] variants show the temptation is real, so they are the thing to watch.

Neutrally, the observable text of some failures changes, and anything asserting on today's wording moves with it. This does not settle how a failure is rendered once it has a type: truncation in a table and the wording of a turn record belong to the surfaces that display them.

Alternatives Considered

Type the failures that cross an operator or agent boundary; leave internal plumbing on anyhow (accepted)

  • Pros: Puts the type where the audience is: a field a person or an agent reads gets a written message, and the compiler enforces that a raw error cannot be assigned to one, Extends a pattern the codebase already uses well rather than introducing a new one, Bounded: the boundary set is small next to 759 context sites, so it can land incrementally without a freeze, Each variant is a place to say what to do next, which is what an agent needs and a backtrace never provides
  • Cons: Two error idioms coexist, so the boundary has to be legible or the split becomes folklore, A conversion is needed wherever internal failure becomes reportable, and a lazy conversion can launder an opaque string into a typed wrapper

Migrate every anyhow site in the controller to typed errors (rejected)

  • Pros: One idiom everywhere, no boundary to explain or police
  • Cons: 882 call sites of churn against a tree with in-flight work on most of them, Spends the same effort on plumbing nobody reads as on the fields that failed an operator
  • Rejected because: The problem is audience, not idiom. anyhow is the right product for a failure whose reader is a developer with a backtrace, and typing those sites buys churn rather than clarity.

Keep anyhow everywhere and write better context strings (rejected)

  • Pros: Cheapest possible change, and would have fixed both observed cases on its own
  • Cons: Nothing stops the next raw context reaching a user-facing field, because nothing in the type system knows which fields those are, Leaves the message at the throw site, far from the surface that has to render it, so wording drifts per call site
  • Rejected because: It fixes the instances and not the class. insert_work_pod was already a mistake anyone would catch in review; the defect is that nothing but review stood between it and a durable field an agent reads.

Keep anyhow internally and map to typed responses at the HTTP layer only (rejected)

  • Pros: One conversion point, and the API already has to map failures to status codes
  • Cons: The worst observed surfaces are not HTTP responses: a park reason and a task note are durable ledger fields written far from any handler, Mapping at the edge means the cause has already been flattened to a string by the time it is mapped
  • Rejected because: It puts the boundary at the transport rather than at the audience. The fields that misled an operator are written into the ledger by background reconciliation, which never passes through an HTTP handler.

ADR-0035: Minted API keys authenticate on the API surface

Status: accepted | Date: 2026-08-29

References: ADR-0048, ADR-0028, ADR-0029

Context

The deployment has no machine credential for /api/*. A minted API key (crk_…) is admitted by require_api_key, which is mounted on the /mcp route and nowhere else. The human guard, require_auth, accepts a session cookie, the edge's header assertions, the static machine token, or a cluster token — and in prod auth.kubeBearer.enabled is false, so the cluster-token path is off. No public device-flow client is provisioned, so the OIDC device grant cannot run either. The crucible-api route exists and is described in the values file as "the machine /api surface for cluster-token bearers", with that bearer path disabled.

The practical result is that an agent can drive the controller only through MCP tools. The CLI in the same binary is the larger half of that crate — 37 subcommands against 34 tools, over the same ops code — and is unusable against the deployment for want of a credential, not for want of features. Every capability an agent needs has therefore been built as an MCP tool, because that is the only authenticated machine path that exists. That pressure shows: the tool surface has grown to 34 entries, and work that would be a shell one-liner becomes a tool.

require_api_key was deliberately kept out of require_auth, and its rationale says why: the two guards stay apart so a bug in either cannot widen the other, matching the shape /metrics, the ingest drop-box and /redeem already use. That rationale is about not merging the credential logic. It is not an argument that the API surface should be unreachable by a key, and the same comment describes the surfaces it guards as "reached by agents holding one long-lived credential" — which is exactly what a CLI-driven agent is.

api_key::looks_like_key already exists, documented as the check require_auth "asks to stay out of the way", and has no callers. The intent was recorded and never implemented.

Decision

A minted API key authenticates on the human router, chosen by the credential the caller presents rather than by the route.

A new require_auth_or_api_key middleware replaces require_auth on that router and does one thing: if the request's bearer claims to be an API key, it runs require_api_key; otherwise it runs require_auth. Neither guard's logic moves, and neither learns about the other. The dispatch is total and decided before any credential is verified.

A bearer that claims to be a key is handled by the key guard and by nothing else. There is no fallback to the human guard on failure, because the fallback is the only way this composition could widen anything: a revoked or malformed key must not be retried as whatever weaker identity the human guard would have allowed. A key that does not verify is refused as a key, and told which of the four things is wrong with it.

What the key stamps is unchanged: the owner's login, subject, and the groups their last login recorded, with every X-Auth-Request-* header stripped first, so the key is the only thing naming the caller. Handlers see the same Resolved they always did and need not know a key was involved.

Consequences

One credential now reaches both surfaces. An agent exports a key and works in a shell, which is what the CLI was built for; crux becomes usable against the deployment without provisioning an IdP client or enabling cluster-token auth. The pressure to express every capability as an MCP tool drops, and the tool surface can be argued on its merits rather than because it is the only way in.

The blast radius of a leaked key grows. It was already an admin-capable credential on the MCP surface, which fronts the same operations, so the change is narrower than it looks — but a key now also reaches any API route the MCP surface does not expose. Key lifecycle, revocation and last used reporting matter more than they did.

Authorization is untouched. A key carries its owner's identity and groups, so the admin and operator lists decide what it may do exactly as they do for that person signed in through a browser. A key cannot become more privileged than the human who minted it.

Anything reading AuthPath sees ApiKey on requests that previously could not occur, which is a widening of the values a handler or audit row can observe on the API surface.

The two-guard separation the original rationale protects is preserved: this composes the guards, it does not merge them. The risk moves from "a bug in one widens the other" to "the dispatch picks the wrong guard", which is a smaller and more testable surface — one predicate over the Authorization header.

Provisioning a device-flow client and enabling kubeBearer remain open and are not foreclosed; a deployment that turns either on gains a second machine path, and the dispatch already routes non-key bearers to the human guard unchanged.

Alternatives Considered

Accept minted keys on the human router, dispatched by the presented credential (accepted)

  • Pros: One credential reaches both the MCP and API surfaces, which is what an agent in a shell needs, No IdP work and no cluster access required, unlike the other two paths, Neither guard's logic changes; the composition is a single predicate over the Authorization header, A key carries its owner's identity and groups, so authorization is unchanged and a key cannot outrank its minter
  • Cons: A leaked key reaches API routes the MCP surface does not expose, Widens a boundary that was drawn deliberately, so the separation argument has to be re-made rather than assumed, AuthPath::ApiKey becomes observable on the API surface, which anything auditing it must expect

Enable auth.kubeBearer so cluster tokens work on the api route (rejected)

  • Pros: Deployment-only change, no code and no new credential type, The api route already exists for exactly this, and the values file describes it that way, A cluster token is short-lived, so a leak expires on its own
  • Cons: Authenticates against the controller's own cluster, so a caller working from any other cluster context is still locked out, Requires every agent to hold an oc login for that cluster, which is a heavier prerequisite than a key, Leaves the MCP and CLI on different credentials, so the surfaces stay divided

Provision a public device-flow client so the OIDC device grant works (rejected)

  • Pros: The most conventional answer, and gives a real user identity with real groups, The CLI already implements the device grant, including caching and refresh
  • Cons: IdP-side work outside this repo, on a realm whose current client registers a callback and nothing else, Interactive by construction: a browser approval is wrong for an unattended agent, which is the case being served, The realm's native scopes omit offline_access, so there is no refresh token and the login expires under a long-running agent

Leave it as it is and keep building MCP tools (rejected)

  • Pros: No security boundary moves, and the two guards stay trivially separate, The MCP surface already works and is the path agents use today
  • Cons: Every capability has to become a tool, whatever its natural shape, and the surface is already 34 entries, The CLI stays complete and unusable, which is a standing cost nobody is paying down, An agent cannot compose operations in a shell, which is the thing a CLI is for

ADR-0036: Deliver run secrets as owner-referenced Secrets on trusted spokes

Status: accepted | Date: 2026-08-29

References: ADR-0028, ADR-0048, ADR-0030

Context

ADR-0028 settled how a run's secrets are owned, stored, and authorized, and chose how they reach the pod: the controller stamps a grant id into the pod spec, and the pod trades that id plus its bound ServiceAccount token for the values over HTTP. The registry half of that decision is built and in production. The delivery half is not: it needs an endpoint on its own Route, a redeem-by deadline, a failure cap that deletes a pod after repeated bad redemptions, a TokenReview per redemption against the pod's home cluster, an in-pod client with capped backoff, and a per-cluster redemption URL that nothing currently supplies to a pod.

That machinery re-implements what a kubelet already does with a mounted Secret: project values into a container, hold them at owner-only modes, keep them off the workspace volume, and update the mount when the object changes. The pull model was chosen to keep values out of spoke etcd and to avoid widening the hub's credential on spoke clusters.

Two facts have since changed the comparison. The operator has declared the spokes trusted, which is what the at-rest argument turned on. And ADR-0048's recorded spoke grant is pods get/list/watch/create/delete, pods/log get, configmaps create/delete namespaced, and tokenreviews create — already write-capable on a namespaced companion object the controller writes per run, so adding secrets create/delete is symmetric rather than a widening. The privilege is close to notional in any case: anything that can create a pod in a namespace can mount that namespace's Secrets.

Ownership is the remaining question the pull model answered for free, since a grant dies with its deadline. Kubernetes answers it directly with owner references, an idiom this codebase already uses to garbage-collect a turn's published objects with the pod that produced them.

Decision

The controller writes a per-run Kubernetes Secret into the target namespace at dispatch and owner-references it to the pod, and the pod mounts it. This supersedes the delivery half of ADR-0028 and nothing else in it: ownership and bindings stay in Postgres, the bytes stay in Vault, the hub stays the only Vault client, and a launch whose scope cannot bind a declared name is still refused before a pod exists.

Delivery won on the same ground the pull model lost. A trusted spoke makes the at-rest objection a preference rather than a threat, and ADR-0048's grant is already write-capable on the same namespace, so the push costs a symmetric verb rather than a new class of authority. What remains is that the pull model reimplements kubelet: the projection, the file modes, the lifetime, and the update-on-change all exist in Kubernetes, and rebuilding them bought an at-rest property the deployment no longer needs.

The decision is scoped to trusted targets. A bring-your-own cluster under ADR-0030 is not one, and placing a value at rest in a cluster the operator does not administer is a different decision than this one.

Consequences

A run's secret set becomes a mount rather than a protocol. The redemption endpoint and its Route, the redeem-by deadline, the failure cap that deletes a pod after repeated bad redemptions, the TokenReview per redemption, the in-pod client, and the per-cluster redemption URL all stop being needed, and a run no longer races its own credential at start.

The value is at rest in the spoke namespace for the pod's lifetime, readable by that cluster's administrators and by anything holding secret read there. Owner references bound the lifetime, not the exposure, and this is the cost the trust declaration buys.

Reads leave no trail. The registry still audits register, rotate, bind, unbind, and delete, so who authorized a value's use is recorded; that a kubelet mounted it is not. An investigation can establish which runs were entitled to a secret, not which ones read it.

Rotation changes hands. A redemption read the current KV version at every pod start; a mount holds what the controller wrote. Propagating a rotation to a running pod now depends on the controller rewriting the Secret, and a pod that starts before that write holds the previous value.

The hub needs secrets create and delete in each target's loop namespace, which is a change to the RBAC that ADR-0048 records and the chart renders.

The decision holds only while targets are trusted. A bring-your-own cluster reopens it, and the pull model this rejects remains the answer for that case if it arrives.

Alternatives Considered

Controller writes a per-run Kubernetes Secret into the target namespace at dispatch, owner-referenced to the pod (accepted)

  • Pros: Kubelet does the projection: owner-only modes, tmpfs, mount updates on change, no in-pod code, Owner references tie the value's lifetime to the pod, so it is collected with the turn, The Secret exists before the pod, removing the bind-then-redeem race the pull model needs backoff for
  • Cons: The value sits in the spoke namespace at rest for the pod's lifetime, A kubelet mount leaves no per-read audit record

Per-dispatch grant redeemed by the pod against the hub over HTTP (the ADR-0028 delivery half) (rejected)

  • Pros: No value at rest on the spoke: it lives in Vault and reaches a tmpfs that dies with the process, Every redemption is an audited event bound to one pod UID, and reads the current KV version at pod start
  • Cons: Rebuilds kubelet projection in application code: a Route, a WAF rule, a deadline, a failure cap, a TokenReview per redemption, and a retrying in-pod client, The grant is unredeemable until the create response binds the pod UID, so a run's start races its own credential
  • Rejected because: Its two advantages are bought at the cost of reimplementing secret projection: the audit trail records reads nobody has asked to see, and keeping the value out of etcd was the point only while the spokes were untrusted. The operator has declared them trusted, which removes the reason the extra machinery existed.

Keep the grant redemption path and give it a per-cluster redemption URL (rejected)

  • Pros: Smallest change from what is already merged: the registry, grants, and the redemption endpoint all ship today
  • Cons: Keeps every cost of the pull model and adds per-cluster URL configuration that must be right on each target before a run can hold a secret
  • Rejected because: Completing a mechanism is not a reason to keep it. The missing URL is the cheapest of its costs, and paying it would settle the design by sunk effort.

ADR-0037: Standing launches: one authorization core with trigger adapters (schedule, watch, portal)

Status: accepted | Date: 2026-09-02

References: RFC-0001, ADR-0033, ADR-0045, ADR-0049, ADR-0028, ADR-0036

Context

The backport pipeline (PR 479) needs Jira tickets to launch a playbook without a human pressing go: a ticket carrying a form label appears, and a run starts with that ticket's key as a parameter. A service-desk style portal is next: an owner publishes a form for a playbook, a requester fills the exposed fields, and a run launches under the owner's authorization. ADR-0045 reserved the Jira slot as a work source and the controller already has a tracker-agnostic tracker.rs boundary with a Jira search primitive waiting for a consumer. The recurring launch surface of ADR-0033 gives schedules an adopted pack snapshot, launcher ceilings, an owner principal and group snapshot refreshed at fire time, a dispatch target, and auto-disable on consecutive failures, so a scheduled run can redeem bound secrets under ADR-0028 and ADR-0036 and fires exactly once across replicas under ADR-0049. All of that lives in playbook_schedules columns and in the schedule sweep's claimed-row-to-launch step.

PR 479 shipped the watch as a daemon singleton: five environment variables on the controller config, one JQL query per deployment, a sweep hardwired to the Jira client, and a second launch-minting path beside the schedule's. Launches carried no owner or groups, so a watch-minted run could not redeem a bound secret. Threading the five values through ControllerCfg touched six test fixtures, and the module could not be composed with anything the launch surface already had. The one thing it got right, and the constraint any design must keep, is the self-retrigger loop: the pipeline's own report is a comment on the driving ticket, which bumps its update time, so a relaunch-on-update sweep feeds on its own output forever.

With three standing triggers in view (a time, a tracker query, a form submission) the question is no longer how to add a Jira watch but where the authorization to launch lives. The recurring policy of ADR-0033 also has to survive the split: a schedule may target a draft head under the administrator's development policy, and the sweep disables draft-head schedules before claiming while adopted-only is effective.

Decision drivers: one launch path for every surface (the ADR-0033 invariant), ownership that resolves secrets for every trigger, a tracker boundary rather than a Jira module, and adding a trigger as an adapter rather than a parallel subsystem with its own copy of the authorization columns.

Decision

Split the standing authorization to launch from the trigger that fires it. A playbook_standing_launches row is the core: adopted pack snapshot, base params, schema digest, ceilings, owner principal and group snapshot with its refresh state, dispatch target, agent provider and model, enabled, consecutive failures. Each trigger is a 1:1 sidecar keyed by the same id: playbook_schedules keeps only cron, zone, next due, last fired, and the dedupe cursor; playbook_watches holds tracker kind, native query, the key parameter, and the watermark; a future playbook_portals holds the slug, the exposed fields, and the groups allowed to submit. Existing schedules migrate onto the core in place, keeping their ids so the dedupe_schedule reference and the SPA links survive. The target lives on the core: target_kind and the adopted snapshot or the eligible draft version, so the fire builder resolves an adopted or draft-head target the same way for every trigger. The pre-claim draft-head disablement of ADR-0033 stays on the schedule trigger, the only one that may name a draft head; watches and portals target adopted revisions only.

One fire path. A trigger claims its sidecar row, supplies a per-firing overlay (a cursor value, an item identifier, a requester's fields) and its launch origin, and a single builder validates the composed params against the target's schema, mints the launch row through the one shared launch path, pins the adopted revision to it, carries the dispatch target and agent pin, and evaluates the owner snapshot for parking. The schedule sweep is refactored onto this builder rather than copied. A run is a run downstream; the only trigger-specific code is claiming and the overlay.

The watch trigger is a discovery source on the tracker boundary: tracker.rs gains a search capability the Jira client implements, the daemon holds the trackers it has credentials for by kind, and a watch save refuses a kind that is not configured. Dedupe is a seen-set keyed by watch and item written in the launch's transaction: one automatic launch per item, ever; the watermark starts at the watch's creation and is the max update time over the seen-set thereafter, so a first sweep does not backfill a board, and an item that raced the watermark slack is re-seen and skipped. A per-item reset re-arms one item for the needs-info round trip.

The portal trigger is direction, not decision: its sidecar would name the exposed subset of params and the groups that may submit, and a submission would launch under the owner's snapshot exactly as a watch or schedule firing does. What a submission must check and record is RFC-0001's to state before the portal is built.

This shape won because the authorization columns and the fire path already exist and are already tested for schedules; it moves them once instead of copying them per trigger, and every later trigger is a sidecar, a claim, and an overlay.

Consequences

  • Every standing trigger launches under a refreshed owner snapshot with the same parking, dispatch target, secret redemption, and audit origin. Adding a trigger cannot regress ownership because the trigger never touches the authorization columns.
  • Migrating playbook_schedules onto the core is a real refactor of the schedule sweep and its queries; the existing schedule tests and the Keycloak fire-time refresh test are the regression net, and ids are preserved so nothing downstream re-links.
  • One-shots (playbook_one_shots) are frozen snapshots that fire once and are left as they are; folding them in as a deferred trigger is a follow-up, not a prerequisite.
  • Once-per-item is the safe watch policy but not the most useful: a ticket amended after a needs-info verdict does not relaunch on its own. The per-item reset and the ordinary launch form are the escape hatches; a smarter relaunch policy can build on the recorded update time without a schema change.
  • A new watch launches nothing already on the board unless the creator sets an explicit earlier bound, the opposite of the singleton's behavior; the form should make that default plain.
  • The draft-head lane is schedule-only. A watch or portal on a draft head would be a new decision, not an extension of this one.
  • The tracker registry stays deployment configuration (one Jira site per controller); per-watch tracker credentials are out of scope.
  • Portal access is group-bound at the sidecar; that is a new authorization surface with no contract yet.
  • The env-config watch has no deployed users, so nothing migrates from it.

Alternatives Considered

Daemon singleton configured by environment variables (PR 479's shape) (rejected)

  • Pros: Smallest diff; no API, SPA, or MCP surface.
  • Cons: One watch per deployment., Launches carry no owner or groups, so bound secrets cannot be redeemed., Five values threaded through the controller config and every test fixture., A second launch-minting path beside the schedule's.
  • Rejected because: Cannot own a run, cannot host more than one watch, and duplicates the launch path instead of composing with it.

A watches table that copies the schedule's authorization columns, sharing only a Rust type and the fire builder (rejected)

  • Pros: Leaves playbook_schedules untouched., Smaller migration.
  • Cons: Every authorization column exists twice, three times once the portal lands., Owner refresh, sign-in state, and auto-disable are queried per table.
  • Rejected because: Copies the columns the design is trying to share; the third trigger would make it a triplication.

One table with a trigger_kind discriminator and nullable trigger columns (rejected)

  • Pros: No join on any read or claim., One migration, one row type.
  • Cons: Every trigger's columns are nullable on every row, so a schedule's cron and a watch's watermark are enforced by CHECK constraints per kind rather than by the table., Adding a trigger widens a table every other trigger reads., The claim statements (a due window, an unseen hit) are unrelated queries that would share one hot table.
  • Rejected because: Per-kind constraints and per-kind claims want per-kind tables; the join is the cheaper cost.

Tracker-side handshake: the launch flips a label or status so the query stops matching, no seen-set (rejected)

  • Pros: The tracker is the single source of truth; no controller state to reset.
  • Cons: Needs write access to every watched tracker., A pack or the controller must know the handshake convention per tracker., A human who removes the label by hand relaunches the ticket silently.
  • Rejected because: Couples the watch to tracker write access and to a per-project labeling convention the controller cannot verify.

Relaunch on update, suppressing updates caused by the controller's own comments (rejected)

  • Pros: An amended ticket relaunches without human action.
  • Cons: Distinguishing self-updates from real ones needs comment-author tracking across every tracker., Any missed self-update loops the pipeline on its own output.
  • Rejected because: The self-retrigger loop is the failure mode this feature must be immune to; the suppression logic is fragile per tracker and the reset covers the round trip.

One module per tracker (jira_watch, github_watch), each with its own sweep and table (rejected)

  • Pros: Each module can use its tracker's native paging and fields freely.
  • Cons: Two sweeps, two seen-sets, two sets of endpoints and forms for the same behavior.
  • Rejected because: The tracker boundary already normalizes hits; the only tracker-specific piece is the search call.

One standing-launch authorization table with 1:1 trigger sidecars (schedule, watch, portal) and a single fire builder (accepted)

  • Pros: Authorization columns and the fire path exist once and are already tested., A new trigger is a sidecar, a claim, and an overlay., Ownership cannot regress per trigger., Immune to the self-retrigger loop by construction.
  • Cons: Migrates playbook_schedules in place and refactors its sweep., Once-per-item needs a reset for the amended-ticket case.

ADR-0038: Generate consumer types from the OpenAPI spec

Status: proposed | Date: 2026-09-03

References: ADR-0033, ADR-0037

Context

Every API resource is spelled up to three times: the domain struct in the controller, an API DTO with a hand-written conversion (33 such conversions across 113 serializable API types), and a hand-written response type in the MCP crate. The MCP copy carries 179 serde defaults whose only purpose is to survive drift from the spec it is not derived from: a renamed field defaults silently instead of failing. The SPA already avoids this by regenerating its TypeScript types from openapi.json on every build.

The watch surface of ADR-0037 added a fourth set of the same shapes. Decision drivers: one spelling per wire shape, drift that fails a build rather than defaulting at runtime, and no new runtime dependency between the controller and its consumers.

Decision

The OpenAPI spec the controller already emits is the single source of the wire shapes. The MCP crate generates its response types from openapi.json at build time, so a spec change that renames or removes a field fails the MCP build; its compact renderers read the generated types by field name. On the controller side an API DTO exists only where it reshapes the domain struct (hides a field, renders a derived value); where it is a field-for-field copy, the domain struct is serialized directly with serde attributes and the parallel type and conversion are deleted. The spec is what the UI and the MCP consume, so the spec, not the Rust types, is the compatibility surface a change is judged against.

Consequences

  • One wire shape per resource; consumers cannot drift silently.
  • The MCP crate gains a build-time step that needs the spec: either the controller binary in the build (as the UI does) or a committed spec snapshot with a freshness check in CI. The freshness check is the cost of not building the controller to build the MCP.
  • Serializing domain structs directly couples the API to their field names; a rename becomes a wire change and shows in the spec, which is the point.
  • DTOs that reshape stay, so the reduction is partial by design.

Alternatives Considered

Keep hand-written types in each consumer, defaulting missing fields (rejected)

  • Pros: No build-time dependency on the spec.
  • Cons: Drift defaults silently at runtime., Three spellings of every shape to keep aligned by hand.
  • Rejected because: This is the current state and the reason for the decision.

A shared wire-types crate depended on by the controller and the MCP (rejected)

  • Pros: Rust types shared directly; no codegen.
  • Cons: The MCP would depend on a controller-side crate and its heavy dependency tree, or the types would move out of the controller into a crate that then owns the API shape., The UI still consumes the spec, so the spec stays authoritative anyway.
  • Rejected because: Makes the Rust types the compatibility surface when the spec already is one for the UI.

Generate every consumer's types from the OpenAPI spec and serialize domain structs directly where a DTO only copies (accepted)

  • Pros: One authoritative shape., Drift fails a build., Matches what the UI already does.
  • Cons: A build-time step in the MCP crate., Partial reduction: reshaping DTOs remain.

ADR-0039: Cedar as the embedded policy engine behind the RFC-0003 decision

Status: accepted | Date: 2026-09-13

References: RFC-0003, ADR-0028, ADR-0030, ADR-0035

Context

RFC-0003 requires one deny-by-default decision for every API operation, taken over a policy set that is stored, validated against a published schema, identified by a content digest, and activated at runtime through the activate action. The decision's inputs are a subject set (a user, the groups a credential proves, and the teams those principals reach through a nested-team graph with a role per team), an actor, an action from a fixed <resource>:<verb> vocabulary, a resource with an owner principal and an ancestor chain, the shares on that resource and its ancestors, and the current time. It must answer with a structured reason naming the rule that decided. Conditions (a not-after time on a rule or share, per-target dispatch admission) are in the first schema version.

The controller has nothing shaped like this. Roles is two login lists and a group list read from the environment; Principals::covers is an exact-string comparison on secrets and personal dispatch targets; the GitHub approver check is a third allowlist. None of them can be replaced without a redeploy, none names a rule, and none can express a team, a share, or an expiry.

An engine has to give us: a policy language with a schema so an invalid set is refused before activation rather than misbehaving at request time; an entity model with hierarchy, so user in group in team in team is a graph the engine walks and not a join the controller writes per route; in-process evaluation, because the decision sits on every request including firings inside the daemon; and diagnostics that name the policy that allowed or forbade, for the structured reason and the audit row.

Decision

The decision is evaluated by the cedar-policy crate (4.x), embedded in the controller process.

A policy set is Cedar policy text plus a Cedar JSON schema, stored as one policy_sets row. Its digest is the SHA-256 of the policy text with \r\n folded to \n and trailing whitespace stripped per line; the schema is versioned separately by the controller (schema_version), because the schema is generated from the Rust action and principal vocabularies and is not policy-author input. The default set ships in the crate as files and is validated at startup with Cedar's validator; a set that fails validation, or fails the two structural checks RFC-0003 names (a rule granting manage-members or transfer on a team below the owner role, or a set under which no user or team principal holds activate), is refused with the failing policy id.

Every policy carries an @id("...") annotation. The decision's structured reason is the set of policy ids in the response diagnostics, or no-rule when the set is empty, and that reason is what the API returns and the audit row stores.

Entities are built per request from what the controller already resolved. The subject is one principal entity (a user, a team firing a standing launch, or a run) whose tags map every principal it acts as to the role it holds there: its own user and each proven group at owner, each team at the role membership resolution produced. The engine never walks the team graph; nested membership, group and rule members, and the configured admin and operator lists are resolved by the controller, which is what RFC-0003:C-PRINCIPALS requires of team principals. The resource is one entity carrying its id, owner, owner_kind, the role the subject holds in that owner (owner_role, absent when none), the best unexpired share role the subject holds on it or an ancestor (share), and the run it belongs to (run). A child's owner_role and share come from its root, which is how the ancestor chain of RFC-0003:C-HIERARCHY reaches the engine. The clock is context.now as epoch seconds; expiry on a rule compares against it, and an expired share is dropped before the entity is built.

Cedar's own policy templates are not used for shares or memberships. Linking a template per share would make the link table a second policy store with its own digest question; attributes keep the set closed, and the default set decides what a share role confers.

The action vocabulary is a Rust enum. The Cedar schema is rendered from it at startup: one entity type per resource type, a group action per verb so a rule can name Action::"read" for every type's read, and one action per <resource>:<verb> with its appliesTo resource type. A route cannot name an action the schema does not know, and the schema's principal types are rendered from the registered scheme list of RFC-0003:C-PRINCIPALS, so an unregistered scheme has no entity type and denies. Route bindings are a static table of method, path, action, and resolver; a test holds it equal to the served OpenAPI route table, and the binding middleware refuses any route the table does not name.

Consequences

The controller gains a new dependency, cedar-policy, maintained by Amazon with a stable 4.x line and a published formal semantics. It is a large crate; the controller's build gets longer.

Policy authors write Cedar. The default set is small (owner grants, team-role grants, platform-admin grant, the open platform-target dispatch grant, the run-principal grants), but an operator amending it needs to read Cedar and the schema. The GET /api/authz/actions and schema endpoints exist so the vocabulary is discoverable rather than remembered.

Per-request cost is the entity build, which is a team-graph read for the subject and a parent-chain read for the resource, plus an in-memory evaluation. The team graph is small and reads are cached within the group refresh window the RFC already permits for group-derived membership; direct memberships take effect immediately.

The structured reason depends on every policy carrying an @id annotation; validation refuses a set with an unannotated or duplicate id.

Shares as entity attributes means share semantics (which actions a viewer, launcher, editor confers) live in the default policy set. That is what RFC-0003:C-SHARING asks for, and it means an amendment to the share vocabulary is a policy-set change plus an enum change, not a schema migration.

Time is context.now in epoch seconds rather than Cedar's datetime extension, so the schema has no extension-type dependency and a policy compares two longs. A later amendment can adopt the extension once the crate's stable line ships it without a feature flag.

Nothing about the storage of teams, owners, or shares is decided here: those are Postgres tables in the controller, the engine only sees the entities the controller builds from them.

Alternatives Considered

Embed cedar-policy and evaluate in-process over entities built per request (accepted)

  • Pros: Schema-validated policies: an invalid set is refused before activation, which C-POLICY requires, Entity hierarchy is native: user in group in team in team is a parents chain the evaluator walks, In-process, no network hop, usable from the daemon's firing path and the API alike, Diagnostics name the policies that decided, which is the structured reason and the audit row, Maintained by Amazon on a stable 4.x line with a formal semantics and a verified evaluator
  • Cons: A large dependency; the controller build gets slower, Operators amending policy write Cedar, a language new to this codebase, Entities are rebuilt per decision, so the team graph read is on the request path

Embed oso (the polar language library) (rejected)

  • Pros: Expressive rule language with role hierarchies in the library's own idiom
  • Cons: The open-source library is dormant since January 2024; development moved to a hosted product, No schema, so a mistyped rule is discovered at request time

Embed casbin-rs with a PERM model (rejected)

  • Pros: Small crate, widely used, model file plus policy CSV is easy to read
  • Cons: No schema or type checking over the policy rows; validation would be ours to write, Matchers are string functions; a nested-team role minimum along a path has no native shape, No structured reason: an allow is a boolean, not the rule that decided

Run OpenFGA (Zanzibar relation tuples) as a sidecar service (rejected)

  • Pros: Relation tuples model nested teams and shares directly, Proven at scale for exactly this ownership-and-sharing shape
  • Cons: A separate Go service with its own datastore; the daemon's firing path would need it reachable, The Rust client is thin and the tuple store duplicates what Postgres already holds about owners and members, The policy set becomes an authorization model plus tuples, which does not fit one digest and one activate action

Biscuit tokens carrying Datalog authorization facts (rejected)

  • Pros: Offline attenuation of a credential is elegant for the run principal
  • Cons: Authorization facts travel in the token; RFC-0003 wants a server-side decision over server-side state, Every existing credential path (session, JWT, api key, cluster token) would need a biscuit minted beside it

Hand-written Rust rules over the same inputs (rejected)

  • Pros: No new dependency and every rule is ordinary Rust the team already reviews
  • Cons: Not a policy set as data: activate would be a redeploy, and a published schema would be prose, The structured reason and the validation rules become a second hand-maintained layer over the match arms

ADR-0040: Retire the stdio MCP server and the personal bearer paths in favor of minted api keys

Status: accepted | Date: 2026-09-15

References: ADR-0035

Context

The crucible-mcp crate grew three ways to prove who a caller is, each built when it was the only path that worked against the deployment: a cluster token read from the kubeconfig and resolved by the controller against its own cluster's users/~ (with an automatic oc login --web on expiry and a kubectl port-forward fallback), an OIDC device grant whose cached JWT the controller validated against a public device-flow client, and break-glass identity headers the process wrote by hand. Around them sat a stdio MCP server, because a stdio server on a laptop was how an agent reached the tools before the controller hosted them.

ADR-0035 admitted a minted crk_ key on the human router beside the MCP surface, and the controller has hosted the same tools at /mcp over HTTP since. In the deployment as run, auth.kubeBearer is disabled, no device-flow client is provisioned, native mode drops asserted headers, and the machine Route exposes /api and /mcp on one hostname. Every one of the personal bearer paths is therefore either off or dead against the controller it targets, and the stdio server duplicates a surface the controller already serves with a credential the SPA already mints.

What the extra paths cost is real: the crate carried a kubeconfig parser, an OIDC client, a subprocess supervisor, and three auth precedence rules, each with a chart knob, a render guard, a golden, and a doc section; and the served co-draft handoff still told a human to run a login that no deployment could answer.

Decision

One credential reaches the controller from a machine: a bearer in CONTROLLER_API_TOKEN, which is a minted crk_ key that authenticates as its owner, or the static controller token that authenticates as nobody. crux reads it from a flag, an env var, or api_token in config.toml under $XDG_CONFIG_HOME/crux, and sends it on every request. It resolves no other credential, spawns no subprocess, and runs no login.

The crate is crux: a CLI, and the tool library the controller embeds at /mcp. The stdio server, the login and logout subcommands, the crucible-mcp binary, and the break-glass flags are removed rather than deprecated, because nothing that spoke to them survives.

The chart no longer offers auth.kubeBearer, auth.jwtBearer, or auth.oidc.deviceClientId; the machine Route is documented as the api-key surface it is. The co-draft handoff mints a key on the Settings page and points the MCP client at the hosted /mcp.

Consequences

An agent in a shell and an agent on MCP hold the same credential, so the question of which surface a capability belongs on is argued on its merits, as ADR-0035 anticipated.

Key lifecycle is the whole access story for machines. Expiry, revocation, and last used on the Settings page matter more, and a leaked key reaches every API route its owner can.

A deployment with no issuer cannot mint a key and so has no MCP path at all; it reaches /api with the static bearer only. Local development of the co-draft loop moves to a deployment with an issuer.

Existing ~/.config/crucible-mcp/config.toml files are not read; the directory is crux, and a file carrying a retired key is refused with the key named, so a stale file fails loudly rather than being half-honored.

The controller's cluster-token and device-client bearer code stays behind env vars the chart no longer sets until its removal, with the tests that prove it, is approved on its own; nothing routes a caller to either path in the deployment as run.

Alternatives Considered

Remove the stdio server and the personal bearer paths; one api-key credential for CLI and MCP (accepted)

  • Pros: One credential and one precedence rule; the crate loses a kubeconfig parser, an OIDC client, and a subprocess supervisor, The chart, the docs, and the served co-draft handoff describe the path the deployment actually answers, The hosted /mcp already exists, so nothing an agent could do is lost on the deployment
  • Cons: A deployment with no issuer loses its only MCP path, Every existing config file and MCP client entry has to be redone once

Keep the stdio server and the login paths behind deprecation warnings for a release (rejected)

  • Pros: Nobody's config breaks on the day the PR lands
  • Cons: The paths are already dead against the deployment, so the warning period protects nothing but the code's own weight, Two credentials and two surfaces stay documented, and the co-draft handoff keeps offering a login that cannot succeed

Admit the static bearer and the open guard on /mcp so an issuer-less deployment keeps an MCP path (rejected)

  • Pros: Local development would keep its MCP loop without a key
  • Cons: Reopens the guard separation ADR-0035 preserved: the MCP surface would take a credential that names nobody, Solves a development convenience with a change to the production surface's admission rule

ADR-0041: Autoresearch controller — close the loop from candidate to draft PR to review feedback

Status: proposed | Date: 2026-06-28

Context

Related: ADR-0004 (the multi-iteration loop + git memory this orchestrates), ADR-0003 (the draft-PR watch that already fires a re-scope on a human PR action — the foundation for the feedback edge), ADR-0002 (the broker + control bridge the controller drives), ADR-0005 / ADR-0009 (deploy-to-rank: a kept composite candidate is a tuple of per-component edits, published as a set of linked PRs), ADR-0010 (a portfolio of such candidates — the controller is what would drive a portfolio without a human babysitting).

The composite P/D rollout-protection loop works end-to-end (ADR-0009 + the mediated composite deploy): the sandboxed agent edits both backends, calls deploy_composite, the broker builds+rolls both, the gate measures, and a kept candidate goes from 8% → 100% live. A second turn seeded with reviewer feedback ("hoist the duplicated factor list into one canonical compatibility function; decide fail-open vs fail-closed") produces an upstream-quality refinement at the same score, opened as linked draft PRs.

The engine part of that is clean. The orchestration part is manual, and that manual work is the actual product gap. Driving the path from "loop produced a good candidate" to "reviewable draft PRs incorporating my feedback" by hand requires:

  1. Saving the kept candidate's diff out of the loop-pod log.
  2. Stuffing it into a configmap, mounting it, and having the wrapper git apply it so the next turn refines instead of re-deriving.
  3. Re-seeding CANDIDATE.md with the feedback, re-pinning the pod, re-running, scraping the log for the new diff + score.
  4. Syncing the forks, synthesizing a capture, running the publisher — then flipping the PRs to draft by hand.

Three concrete defects fall out of that, each a seam the controller should own:

  • Wrong base sha. The loop wrapper emitted git rev-parse HEAD after the loop committed the kept candidate, so the "base" was the candidate commit, not the upstream sha — it doesn't exist upstream, and the publisher's base-content fetch 404s. (Fixed locally by capturing the base before the run.)
  • Layered diff. Because the prior candidate was applied via a configmap, the engine's captured diff was a delta on the applied prior, not a full pristine-relative candidate. The publisher needs the full diff.
  • Non-draft PRs. The "draft PR" publisher opened non-draft PRs. (Fixed: pass draft=true.)

None of this is a research problem. The loop is the search; everything above is plumbing between the loop, the broker, the publisher, and the human reviewer — and it should be one command, not a session of kubectl and log-grepping.

Decision

Introduce an autoresearch controller: a thin orchestrator that drives the existing pieces end-to-end and adds one new edge — PR review comments feeding back into the loop as a steer. It owns the candidate→PR→ feedback cycle; it does not reimplement the loop, the broker, or the publisher.

  ┌────────────────────────── controller ──────────────────────────┐
  │                                                                 │
  │   run N turns (ADR-0004 git memory carries the candidate)       │
  │        │  kept candidate (one pristine-relative diff per repo)  │
  │        ▼                                                        │
  │   publish-on-keep ──► linked DRAFT PRs (ADR-0003 publisher)     │
  │        │                                                        │
  │        ▼                                                        │
  │   watch PRs (ADR-0003 watch.rs) ──► review comments arrive      │
  │        │                                                        │
  │        ▼  comments → steer text                                │
  │   control-bridge `steer` (control.rs) ──► next turn refines     │
  │        │                                                        │
  │        ▼  push the refined commit onto the SAME PR branch       │
  └─────────────────────────────────────────────────────────────────┘

The foundations already exist — the work is orchestration plus the comment→steer translation:

CapabilityWhere it lives todayController's job
Carry a candidate across turnsADR-0004 loop driver + git memory (--iterations N, one pod)use it instead of configmap-applying a prior diff
Inject feedback into a running turncontrol.rs steer (append to the steer file via the control port)translate PR comments into steer text
Open linked draft PRs on a kept candidatenative in publish.rs (open_composite_prs): one cross-linked draft PR per component fork, pushed from the live per-component git reposinvoke on keep (already wired)
React to a human PR actionADR-0003 watch.rs (spawn_approval_watch already fires a re-scope on approval)generalize from "approved?" to "new review comments?"
One pristine-relative diff per componentthe loop's staged_diff is already baseline-relativetrack the true upstream base (capture at run start, not post-commit) and publish that

Loop semantics for refinement. Refining a kept candidate is a normal multi-iteration run (ADR-0004), not a fresh pod with a hand-applied prior diff. The kept candidate is the loop's committed state; turn N+1 sees it as the baseline and the captured diff stays full and pristine-relative. The configmap-apply hack exists only because refinement runs as a separate pod — the controller removes the need.

The feedback edge (the new part). When a draft PR receives review comments, the watcher (the ADR-0003 pattern, extended to read pulls/comments + issues/comments) collects them, formats them as a steer ("reviewer asks: …"), and either steers a live turn or seeds the next turn's CANDIDATE.md. The refined candidate is pushed onto the same PR head branch, so the PR updates in place and the review thread stays coherent. Score must hold (the gate still gates); a refinement that regresses the metric is discarded exactly as any other candidate, and the controller says so on the PR.

Scope guard. The controller is per-goal and human-gated at the edges: a human opens the review, a human merges. The controller never merges, never force-pushes, never posts as the user (it pushes commits to its own dedicated branch and updates the PR body; review replies are drafted for a human to post). It is the connective tissue, not an autonomous committer.

Consequences

  • The manual dance becomes crucible loop --turns N --publish-prs --watch-feedback (or a small autoresearch wrapper binary). The three defects above are fixed at their source rather than worked around per run.
  • Reviewability stays first-class. Every candidate is a draft PR; every refinement is a push to that PR; every keep/discard decision is visible. This is the ADR-0010 "reviewable candidate" contract, now with a feedback channel.
  • Composite-aware. A kept composite candidate is a set of linked PRs (ADR-0009); a reviewer comment on one repo's PR can steer a cross-cut refinement that touches both. The controller keeps the set linked.
  • Open questions.
    • Steer vs reseed. Feeding comments into a live turn (steer) vs the next turn's CANDIDATE.md — live steering is more responsive but races the turn; reseeding is simpler and deterministic. Start with reseed; add live steer behind a flag.
    • Comment selection. Not every comment is actionable (nits vs design asks). Either pass all review comments verbatim and let the agent triage, or gate on a label/keyword. Start verbatim.
    • Base drift. Upstream moves under a long-lived PR. The controller should rebase the candidate onto current upstream before re-publishing (the publisher already 3-way-applies; make it explicit).
    • Cost. Each feedback round is a full deploy+measure turn (GPU + build). The per-turn budget (ADR-0002 turn::Budget) and the keep/discard gate bound it, but a human still opts in to each round.

ADR-0042: Pluggable rig backends — defer the rig to an external renderer (burrito + Kueue + ephemeral namespaces)

Status: proposed | Date: 2026-06-30

Context

Related: ADR-0011 (rendered deployments — this generalizes the rig half into a pluggable backend), ADR-0009 (the composite rig that needs standing up), ADR-0005 (forge builds the candidate the backend applies), ADR-0004 (park/resume — the loop must survive a preempted/recreated rig), ADR-0002 (the broker still holds the privileged approvals), ADR-0001 (the rig is the frozen workload, out of the loop's reach).

Standing up a crucible loop on a cluster today is a six-step, personal-credential runbook (see Getting started, Act 3), and it doesn't scale to a team:

  • Every teammate replays admin work, with their own tokens against personal namespaces.
  • The rig is hand-rolled. deploy/<domain>/<overlay>/up.sh is bespoke per rig: a helm upgrade, a modelservers.yaml, a GAIE CRD check, an HF-token secret, and a gpu-pruner.io/ack-until=+24h annotation hack to stop the cluster's idle-GPU reaper from reclaiming the rig during the agent's think-time. Fighting the scheduler instead of using it.
  • No isolation, no lifecycle. The rendered loop pod is <composite>-loop in a shared persistent namespace, so two teammates collide; nothing reclaims a rig when a run dies.

The sister repo neuralmagic/llm-d-downstream-infra already runs llm-d benchmarks at team scale in CI on burrito: rigs are helmfiles, burrito run renders them, wraps the workloads in an AppWrapper, admits via Kueue in an ephemeral namespace, overrides the candidate with --vllm-image, injects Vector metrics sidecars (→ NDJSON on a PVC), runs a GuideLLM benchmark Job, and cleans up. Shared base/kueue/ holds the ClusterQueue, ResourceFlavors and TAS topology; the pull secret is copied from a shared llm-d-pull-secrets namespace; cross-repo auth is a GitHub App identity, not personal PATs.

That's the substrate Act 3 should sit on. But crucible should not marry burrito: burrito is one opinionated way to materialize a rig, and adapting every future domain to its model is expensive. The right abstraction is to treat rig provisioning as a pluggable backend that crucible defers the render to, the same way it already defers proposal to a pluggable [agent].backend. Burrito is the reference backend; the existing helm path becomes another.

One genuine mismatch to resolve, not assume away: burrito's rig model is one-shot jobs (deploy → benchmark → teardown), while crucible's loop is iterative. Whether crucible holds one warm rig and swaps candidates into it, or runs a burrito one-shot per candidate, is the central open question this ADR's spike answers (see Open question).

Decision

Introduce a pluggable rig backend and make burrito the reference implementation. Split Act 3 into a platform layer (set up once) and a one-command self-serve layer (any teammate).

1. A first-class [rig].backend, analogous to [agent].backend

The manifest selects how the live rig is materialized, the engine doesn't hardcode it. burrito and helm are compiled-in backends; command is the escape hatch that runs user-provided commands, exactly mirroring [agent].backend (local/openshell/command).

The rig definition can be external (referenced, shared with CI) or inline (vendored in the domain) — both are supported:

# external: reference a rig def that lives in llm-d-downstream-infra (single source of truth with CI)
[rig]
backend = "burrito"
def     = "llm-d-downstream-infra//ci/helmfiles/pd@<ref>"

# inline: vendor the helmfile/overlay in the crucible domain
[rig]
backend  = "helm"
helmfile = "rigs/lora-routing/helmfile.yaml.gotmpl"
overlay  = "rigs/lora-routing/overlays/ci"

crucible defers rendering to the backend (an external renderer): burrito renders its own helmfile → AppWrapper → Kueue; it is not crucible's job to know the rig's YAML. If burrito needs a contract change to serve the loop, that's acceptable and expected (see the spike).

2. The RigBackend contract surface

up(namespace)            -> RigHandle { endpoints, ... }   # render + admit + wait-ready, into a given ns
apply_candidate(ref)     -> ()        # swap the digest-pinned candidate onto the rig
benchmark()              -> Bundle    # run the workload, return the metrics bundle location
down()                   -> ()        # teardown (and, where the backend owns it, the rig)
  • The backend owns apply_candidate. forge builds the digest-pinned candidate (ADR-0005); the backend swaps it onto the rig because it knows the deployment/container topology (burrito's --vllm-image is exactly this). crucible never needs per-rig topology knowledge.
  • The backend runs the benchmark. Measurement is post-hoc over the bundle (§4).
  • up/down are deliberately vague on rig lifetime (one warm rig vs one-shot per candidate); the spike settles that and pins the contract (see Open question).

3. crucible owns the (ephemeral) namespace; the backend renders into it

crucible creates the run's namespace (plus minimal RBAC) and injects it into the backend (up(namespace)), rather than letting each backend invent its own. burrito already supports rendering into a pre-created namespace, so this composes. Today rigs may land in a persistent namespace; the direction is a fully ephemeral, run-id-scoped rig namespace (crucible-<rig>-<run_id>), which is what gives collision-free concurrent team use and automatic cleanup. Owning the namespace centrally (not per-backend) keeps that property backend-agnostic.

4. Measurement: the backend benchmarks, the gate reads the bundle

Each iteration the backend runs its benchmark (burrito's GuideLLM job), and crucible's gate reads the resulting metrics bundle post-hoc to emit {valid, score}. Because the workload is the same GuideLLM benchmark CI runs, the loop optimizes the exact number CI regresses on, closing the gate/CI drift gap from earlier isolation work.

Amendments (2026-07-01):

  • Scoring locus: the engine, not the broker or the backend. The backend runs the workload and produces the bundle; the engine runs the domain's measure_cmd over it — measure_cmd stays the single engine↔domain contract (ADR-0009's rule, unchanged). The §11 rig tools only relay the engine-computed {valid, score}; no backend ever computes a score.
  • Paired baseline per one-shot. Under the one-shot model every measurement lands on a fresh rig instance, so cross-instance variance would sit inside the gate. Each one-shot job therefore includes a baseline reference measurement on the same rig instance, and the gate scores the candidate as the delta against it (ADR-0001 safeguard 3 adapted: the frozen baseline becomes a frozen paired procedure). Costs ~2× benchmark time per candidate, not 2× rig standups.
  • Run identity. The rig definition ref ([rig].def / helmfile@ref) and the backend name+version join the ADR-0008/0009 comparability tuple; two runs are comparable only if those match too.

The bundle and the measure_cmd are co-designed as a pair — a domain concern — and this refactor does not change that. crucible does not impose a normalized cross-backend bundle schema: the backend produces a bundle, and the domain's measure_cmd is authored against that bundle, exactly as today. Re-pointing a rig at a different backend may mean re-co-designing the bundle↔measure pair for it; that is expected, not a regression. (The bundle is the whole Vector→NDJSON set burrito already produces, so for the burrito backend the gate is typically a post-hoc read with no bespoke scraping.)

5. Port the existing helm rig as a backend (don't force burrito on everyone)

Define the boundary and ship two real implementations: the existing up.sh/helm logic as a helm backend, and a burrito backend. This proves the abstraction with more than one impl and keeps a no-Kueue / less-opinionated option for domains that shouldn't take on burrito's full model. Kueue admission (via the burrito backend) replaces the gpu-pruner ack hack for rigs that use it.

The helm backend is ported at parity first — today's persistent shared namespace + gpu-pruner ack — to keep churn on the working path low. Whether the helm backend also adopts the ephemeral namespace model (§3) is deferred until the spike settles burrito's namespace lifecycle, then revisited; ephemeral + Kueue is the burrito backend's property to start.

6. Kueue: a dedicated cohort for interactive loops

Interactive loops get their own ClusterQueue / quota, separate from CI benchmarks, so the two don't preempt each other. A human is waiting on a loop; a CI run is retryable; carving the GPU pool by cohort keeps both predictable rather than racing.

7. The loop pod stays crucible's; the backend provides only the rig

crucible run creates the lightweight (CPU) loop pod into the run namespace; the backend provides the GPU rig. (How the loop pod itself is rendered, reuse ADR-0011 or create it directly, is an implementation detail under crucible run, not a fork in this decision.)

8. Credentials (W4) are a separate later phase

The identity overhaul, shared llm-d-pull-secrets source ns, a team machine identity for push/PRs, workload identity for cloud, External Secrets, is deferred to its own phase after W0/W3. The spike and sprint-1 reuse existing creds. The ADR-0002 trust boundary is unchanged throughout.

9. One cluster now, multi-cluster-ready

Build for wharf, but keep the backend cluster-agnostic and per-cluster facts in the profile, so a second cluster is a new profile, not a refactor. No multi-cluster orchestration yet.

10. The self-serve surface

# platform owner, once per cluster (mostly: depend on llm-d-downstream-infra/base + the loop cohort)
crucible platform init --cluster wharf

# any teammate, per run
crucible run <rig> --goal goals/<g>.md     # run-id ns + backend-provisioned rig + loop + tail, isolated
crucible ps                                # active runs across the team

11. Agent-facing rig tools over MCP, with per-run delegated auth (ADR-0002)

In the one-shot model the sandboxed agent has zero cluster privilege (ADR-0002 deny-by-default), so "request a rig/benchmark job and monitor it" is a set of mediated broker tools, extending the proven async deploy_composite → deploy_status pattern. Dedicated tools (not a reused deploy tool):

rig_run(rig, candidate_ref)      -> { run_id }          # submit a one-shot job, returns immediately
rig_status(run_id)               -> { phase, queue_pos, result? }
rig_logs(run_id [, cursor])      -> { chunk, next_cursor }   # on-demand, pull-based
rig_result(run_id)               -> { bundle_ref, result }
  • Transport stays stateless. The agent polls rig_status (the broker is stateless because stateful mode currently makes Claude Code surface zero tools, the get_info capability gotcha). rig_status returns a coarse phase (queued/admitted/running/benchmarking/done/failed) and the Kueue queue_pos while waiting; on completion it carries the bundle_ref and the broker-computed {valid, score, note}. rig_logs is pulled on demand (optionally cursor-paged) so a long log tail never floods the agent's context. SSE/streaming progress is a future option, contingent on fixing the stateful tool-discovery regression first.
  • Per-run delegated auth. The broker mints a short-lived, per-run credential (a per-run ServiceAccount token) scoped to the run's ephemeral namespace, and the one-shot job runs under it, so the job's blast radius is the run namespace. The agent's MCP call carries no cluster privilege; the broker holds the host creds and does the minting (the ADR-0002 boundary is unchanged). This in-cluster per-run delegation is distinct from the deferred W4 work (where the broker's own creds come from, machine/workload identity, ESO): the rig tools get scoped delegation now; W4 is the credential-source overhaul later.
  • Score stays host-side, computed by the engine. On job completion the engine runs the domain's measure_cmd over the bundle (§4 amendment); the broker relays the result in rig_status/rig_result. The gate never enters the sandbox and never leaves the engine (ADR-0001/0009 trust boundary).
  • Admission is quota + cap, never open-ended. rig_run is agent-initiated GPU spend, so it is bounded twice: the loop cohort's ClusterQueue quota (§6, team fair-share) and a broker-enforced per-run budget (max rig runs / GPU-minutes from the manifest, like the existing per-turn cap), so a looping agent can't starve its own cohort. This is ADR-0002's "the agent cannot provision" stated for rigs: the agent asks; quota and cap admit.

Open question: the one-shot vs warm-rig spike

burrito's entire rig model is one-shot jobs. Before W0 commits to a contract, a spike must prove that model is compatible with an iterative agent loop, specifically that the agent can request a rig/benchmark run and monitor its progress, and the measurement gates produce valid scores off the bundle. Two candidate shapes for apply_candidate + benchmark:

  • (a) Warm rig: up once, hold the rig admitted for the loop, apply_candidate swaps the image in place, benchmark re-runs against the warm rig. Fast per iteration; needs a burrito "admit and hold, don't tear down" affordance (--no-cleanup + pre-created ns gets close).
  • (b) One-shot per candidate: each iteration is a fresh burrito run (rig + candidate + benchmark + teardown) that the agent requests and monitors. Uses burrito as-is; pays a cold start per iteration.

Working hypothesis: (b) one-shot-per-candidate. It uses burrito untouched, the agent requests a fresh burrito run per candidate and monitors it via the rig_* tools (§11), trading a per-iteration cold start for zero burrito-side changes and the simplest lifecycle (no warm rig to hold or garbage-collect). The spike's job is to refute it, the failure modes to watch are the cold-start cost per iteration, the agent's request/monitor UX over polling, and gate correctness off the bundle (scored as the paired-baseline delta — see the §4 amendments). We fall back to (a) warm-rig only if (b) proves too slow or flaky.

The spike confirms or refutes (b) on a real rig (lora-routing) and feeds the W0 contract: (b) holding lets W0 ship without a burrito-side change (and makes the "hold the warm rig" affordance unnecessary); (a) winning files that affordance. Its outcome also gates the helm backend's namespace model (§5): once the burrito lifecycle is settled, we revisit whether helm adopts the ephemeral ns or stays persistent.

Workstreams

#WorkstreamNotes
SpikeOne-shot vs warm-rig on lora-routing (agent request/monitor + gate correctness)Picks (a)/(b); de-risks W0
W0[rig].backend abstraction + RigBackend contract; port helm, add burritoKeystone; retires up.sh/gpu-pruner hack
W2Broker rig_* MCP tools (run/status/logs/result) + per-run scoped credential mintingThe agent's mediated path to one-shot jobs (§11); pairs with W0
W3crucible run <rig> one-shot (preflight → backend up → loop → teardown), run-id ns isolationSprint 1 with W0
W1Deploy profile → cluster-identity base + per-run overlay (no copy-paste)Multi-cluster-ready
W5crucible ps / team run status (wrap burrito list/monitor + the leaderboard)
W6crucible platform init (depend on llm-d-downstream-infra/base + the loop cohort)
W4Credentials: shared pull-secret ns + machine identity + workload identity + ESODeferred to its own phase
Depburrito "hold the warm rig" affordance (--no-benchmark --wait-ready)Only if the spike picks (a)

Slicing: spike first; then sprint 1: W0 + W2 + W3 (backend + its mediated tools + the one command); then W1 + W5 + W6; then the W4 phase. Tracked in epic ACME-8332.

Consequences

Good:

  • Act 3 collapses to crucible run <rig>; the gpu-pruner hack dies; concurrent team use falls out of run-id-scoped namespaces + a dedicated Kueue cohort.
  • Rig provisioning is pluggable (helm and burrito to start), so no domain is forced onto burrito's opinionated model, and the abstraction has two real impls from day one.
  • Rigs (under the burrito backend) are helmfiles shared with CI, and the gate measures the same GuideLLM workload CI does, so loop and benchmark can't drift.
  • Personal tokens leave the per-run path (in the W4 phase); the ADR-0002 boundary is unchanged.

Costs / risks:

  • A new abstraction ([rig].backend + RigBackend) plus a dependency on burrito + Kueue + AppWrapper + the llm-d-downstream-infra/base platform. Local, cluster-free runs (Act 1/2) are unaffected.
  • The one-shot/warm-rig mismatch is unresolved until the spike; W0's contract depends on its outcome.
  • Kueue preemption (even within a cohort) can recreate a rig mid-loop; the loop must treat that as discard-and-resume via the ADR-0004 state model (needs a "rig recreated/evicted" exit path).
  • New broker machinery: the rig_* tools plus per-run credential minting (a per-run SA token scoped to the ephemeral ns) — the broker gains the ability to mint and scope tokens.
  • Monitoring is poll-only (stateless transport); richer streaming progress is blocked on first fixing the stateful tool-discovery regression.
  • Two contracts to version: the backend boundary and (for burrito) its CLI/helmfile/guides refs.

Alternatives Considered

Adopt burrito directly as the rig provisioner (no backend abstraction) (rejected)

  • Rejected because: burrito is one opinionated way to materialize a rig; adapting every future domain to its model is expensive. Rig provisioning becomes a pluggable backend crucible defers the render to, with burrito as the reference implementation.

One-shot burrito run per candidate (rig + candidate + benchmark + teardown each iteration) (accepted)

Warm rig held for the loop, candidate swapped in place per iteration

ADR-0043: The outer loop — discovery and scheduling (crucible triage + crucible autopilot)

Status: proposed | Date: 2026-07-02

References: ADR-0041, ADR-0042, ADR-0044

Context

Related: ADR-0012 (the scoping pipeline each reconcile drives), ADR-0003 (the park/resume machinery the approval gate reuses), ADR-0041 (the PR feedback edge that closes the cycle), ADR-0011 (the renderer that gains a controller-Deployment template), ADR-0042 (admission for the pods the controller launches), the analyze/ judge-tier ranking (the discovery logic this promotes to an engine primitive). External: Loop Engineering (Steinberger / Cherny / Osmani, June 2026) — the five-moves vocabulary this ADR borrows.

Loop Engineering decomposes one turn of a self-running system into five moves: discovery (the loop finds its own work), handoff (each task gets an isolated working directory), verification (an independent check that can say "no"), persistence (state on disk, not in a context window), and scheduling (a real trigger, not a human pressing go). Skip one and you get a named failure: the blind, tangled, nodding, amnesiac, or manual loop.

Scored against that taxonomy, crucible today is a superb single turn wrapped in a blind, manual loop:

MoveCrucible today
VerificationMeasurement judge + [judge.selftest] + crucible check — stronger than the paper asks (deterministic, not an LLM skeptic)
HandoffWide-round worktrees, per-lane sandboxes, rendered loop pods
PersistenceGit-as-memory, session NDJSON, RESULTS.md, S3 publish
DiscoveryA human picks the issue. The analyze/ tier ranking exists but is operator-run scripts
SchedulingEvery run is a human kubectl apply or a just target

The missing two moves are outer-loop plumbing, not engine work: everything a reconcile needs (scope, check, selftest, render, publish, watch-pr, ps) already exists as a subcommand. What does not exist is the thing that chains them on a timer and remembers what it already tried.

The paper's costs sharpen the requirements. Its four silent debts (verification debt, comprehension rot, cognitive surrender, token blowout) all have the same guard: hard caps set before the loop runs unattended, plus a human checkpoint the loop cannot remove. ADR-0012 already made the scope-approval gate mandatory; ADR-0041 already made kept candidates land as draft PRs that are never auto-merged. Fully automating the loop therefore means automating everything between the approvals — never the approvals.

Decision

Add the outer loop as a discovery primitive plus a small controller daemon, with SQLite as the working state and an append-only event log as the history.

  1. crucible triage — discovery as an engine primitive, and only discovery. Scan a repo's open issues and upsert them into the state database (state/outer.sqlite): one row per issue with title/author/labels, status (new / scoped / awaiting-approval / running / pr-open / parked / done), and the evidence pointer — no tier. Idempotent by construction — an issue already tracked is never re-fetched unless its upstream state changed. Runs standalone (it opens the same database the daemon uses), so discovery is useful the day it lands.

    All tiering is the LLM ranker, at reconcile time. The tier vocabulary: T0 test-gate bug / T1 measured-workload perf / T2 / T3 multi-component-live-rig required / N-unscopeable. The first step of reconciling a new issue is one bounded ranking call (the analyze/ rank-prompt.md judgment, in-process per the execution ruling below), recording tier + rationale as the row's evidence and the call's cost to the ledger, cached by issue-content hash (a comment that changes the issue re-ranks; a re-poll never re-spends). An N verdict parks the issue (machine, "unscopeable per ranker"); a T3 verdict leaves the row new but excluded from the pick behind a profile flag — nothing is wrong with a T3 issue, the v1 autopilot just can't build its gate (see "Not GPU-rig autopilot (yet)"); the T3 queue is the standing demand signal for ADR-0042. Unranked rows (tier NULL) sort first in the pick by priority/recency — "rank me" is the cheapest possible reconcile. A failed or cap-declined ranking leaves the tier NULL and retries through the queue's backoff; there is deliberately no keyword-heuristic fallback — a prose-keyword classifier was built as a prior and then deleted: it misranked, every issue gets exactly one cached LLM verdict anyway (identical spend), and a wrong cheap tier is worse than an honest NULL. Ranking inside reconcile keeps polling free and puts all model spend under the §4 caps; the automated call follows the house rule for headless model use (Vertex, never a personal account).

    The ranking ladder has a second rung: a grounded confirmation gates every scope spend. The API verdict above is cheap and text-only — good for ordering the queue, not good enough to spend a scope turn on alone. Before reconcile_new hands an issue to scope --propose, it requires a code-grounded verdict: the same checkout-backed turn the low-confidence escalation already runs (crucible rank-grounded, one sandboxed agent turn against a maintained per-repo checkout), now required on every T0/T1/T2/T3 row the API tier clears, not just the ones the text-only ranker flagged as unsure. Its tier, when it confirms or improves on the API one, overwrites the row and the scope turn proceeds; a demotion to N or a failed/verdict-less grounded call parks or defers exactly like the API tier's own N-park / no-verdict-no-spend rules. Cached by content hash the same way the API tier is (grounded_content_hash), so a re-sweep of unchanged content never re-spends. The economics: a 20-issue comparison against llm-d-inference-scheduler found grounded ranking runs roughly 9x the API arm's cost but a wasted scope turn costs roughly 20x more than that — the gate pays for itself the first time it catches one. It is enabled by default (ControllerCfg::prescope_grounded, CONTROLLER_PRESCOPE_GROUNDED to opt out for GPU-free / quick-loop scenarios where an occasional wasted scope turn is cheaper than grounding every issue).

    The dominant failure mode it catches is staleness, not mistiering. The same comparison found the API ranker calling T0 ("do this now") on issues whose fix was already implemented in the checkout — a scope turn on a solved problem is pure waste, and no tier would have caught it. The grounded verdict vocabulary therefore has a disposition beside the tiers: stale, which supersedes every tier definition — if the requested change already exists in the code, the verdict is stale, never a tier, with file:line evidence in the rationale. Reconcile treats stale as a park (machine, "stale per grounded ranker: already implemented"), visible in the inbox with the rationale as the evidence; it does not auto-comment upstream (a approval for later).

  2. crucible autopilot — the controller daemon. Its core is a pure reconcile function over the database: pick the top-ranked new issue within budget → scope --propose it (ADR-0012 S2; check + selftest are the mechanical "no"-sayers, a dead proposal parks with its reason) → a surviving pack goes to the approval gate (draft PR, ADR-0012 S4) and the issue sits in awaiting-approval → on approval, render the loop pod (ADR-0011) and launch it under the admission caps → record outcomes. Around that core the daemon holds the event edges that made cron lose: the ADR-0003 approval signal, a kube watch on its loop pods (completion handled in seconds, not next tick), and the ADR-0041 review-comment poll — absorbing today's separately-babysat watch-pr process into the one lifecycle. Every state transition is one SQLite transaction, so overlapping work cannot double-scope an issue, and each transition also appends to an NDJSON event log (the session-log discipline applied to the outer loop): the database is what is, the log is what happened.

    Internally, events feed a work queue — the controller-runtime workqueue pattern, borrowed without the CRDs. Sources (the discovery timer, the approval signal, the pod watch, the review-comment poll) never act and never touch the database; they only enqueue a key (owner/repo#N). A worker dequeues a key and runs the reconcile function for it, reading current state from the database — so an event is a hint to look, never a payload to trust, and a stale or duplicate event is harmless by construction. Queue semantics: dedup/coalesce by key (ten pod events while an issue is queued collapse into one reconcile), exponential-backoff requeue on a failed reconcile, park with the recorded error after the retry budget. The queue itself is in-memory and deliberately lossless-by-derivation: on startup the daemon enqueues every non-terminal row from the database, so a dropped event or a crash costs latency, never work. One worker for v1 — serialized reconciles keep SQLite's single-writer trivially true; per-key sharding is the later concurrency knob if discovery volume ever warrants it.

    crucible autopilot --once is the same machinery run to quiescence: enqueue all non-terminal rows, drain the queue, exit — the dev/test/break-glass mode. Kill the daemon at any point and restart it; it re-derives everything from the database. There is no in-memory state a restart can lose.

    The state model itself — the DB-as-rebuildable-index truth rule, controller-pull ingest, per-candidate granularity, the schema, migrations, the override API, and the sqlx choice — is its own record: ADR-0044. The short version: SQLite generalizes into the orchestration ledger (provenance graph, admission ledger, later the ADR-0042 rig-lease table and wide-round resume) for exactly one resident writer, while runs stay self-contained log-and-git artifacts that never open a database.

  3. Deployment — crucible deploy render --controller renders a one-replica Deployment (one more ADR-0011 template: the loop pod minus the agent, plus the state volume for the database + event log). The kubelet restart policy is the supervisor; single-replica means single-writer, which is exactly what SQLite wants — no leader election, no DB server, no HA machinery for a component whose failure mode is "restarts and reconciles." Discovery cadence, budgets, and caps live in the deploy profile — the environment half — not the manifest.

  4. Caps, set before it runs unattended (the paper's token-blowout circuit breakers, all enforced inside the reconcile function):

    • per-reconcile cost ceiling (scope turns + launched runs);
    • max concurrent loop pods (admission = the ps count against the profile's cap, until ADR-0042's Kueue cohort quota subsumes it);
    • max new scopes per day;
    • a global daily ceiling that parks the autopilot entirely when crossed.
  5. The approvals stay open. Two human checkpoints, both already load-bearing, both unremovable by configuration: pack approval before any budget is spent on an issue (ADR-0012 stage 4), and PR review before anything merges (ADR-0041). The inbox for everything else is the database's parked rows — the controller never silently drops work, it parks it with a reason a human can query.

  6. Upstream issue changes re-rank; what else they may do depends on status. A poll with a per-repo watermark (GET issues?since=…, conditional requests — no webhook, no public ingress) is one more source: it enqueues changed issue keys, nothing else. Because ranking was never materialized (the pick is ORDER BY tier, priority at reconcile time), re-ranking is just re-tiering the row — no queue surgery, idempotent under duplicate events, and every tier flip is an event-log line the ranker can later be tuned against. Beyond re-tiering, a comment's power is status-dependent:

    • new — re-tier, done.
    • parked — a machine park (no repro, can't isolate) auto-unparks to new when the issue content materially changed, reason recorded; a human park is sticky — no comment overrides an operator's "don't touch this."
    • scoped / awaiting-approval — the goal froze from the old text, so the pack may be stale: mark the row stale and refresh a comment on the approval PR with the upstream diff. The human at the approval decides — approve anyway or bounce to re-scope. No automatic re-scope: comments must not be able to burn scope turns.
    • running — the ADR-0001 wall: comments never steer a live run. One exception: the issue closing upstream stops the run (via the run's own stop machinery, so it publishes what it has — a kept candidate still lands its draft PR) and parks the row as "upstream closed." Budget is not spent on solved problems.
    • done / pr-open — record only.

Failure modes, by the paper's names

  • Blind loop — killed by workstream O0: discovery is triage, not a human's morning.
  • Manual loop — killed by O2: the controller Deployment is the trigger; the operator's laptop being closed no longer stops the research program.
  • Nodding loop — already dead: the judge is a measurement the agent cannot edit, selftest- proven to discriminate; scope proposals die mechanically in check/selftest before a human reads them.
  • Amnesiac loop — killed by the state database; reconciles share memory on disk, runs share RESULTS.md and S3, review feedback reseeds the next run.
  • Tangled loop — already dead: per-lane sandboxes and per-candidate worktrees shipped in v0.1.0.

What this is NOT

  • Not auto-merge, not auto-approve. The two approvals are the point. An autopilot profile cannot express "skip approval"; there is no flag.
  • Not an adaptive loop. The ADR-0001 wall stands reconcile-to-reconcile: scoping adapts once per issue, freezes, and the optimizing run never overlaps its own scoping.
  • Not an operator. No CRDs, no webhooks, no reconciling of Kubernetes objects it didn't create. It is a daemon, and that call deserves its reasoning: a CronJob-tick design was considered and lost because every interesting edge in this loop is event-shaped — approval granted (ADR-0003 is a park/resume waiting for a signal), loop pod finished (a kube watch), review comment landed (ADR-0041) — and cron converts each into up-to-one-interval of latency. Worse, the cron design didn't remove the daemons, it scattered them: watch-pr and the control bridge are already long-running processes someone babysits separately. One controller unifies those lifecycles. The escape hatch keeps it honest: the reconcile core is a pure function, --once runs it by hand, and the daemon holds no state a restart can lose.
  • Not a database server. SQLite on the state volume, single-writer by construction (one replica). Transactions and SQL are what the access patterns need (claim an issue, flip a status from an external signal, query the inbox); HA Postgres is what they don't.
  • Not GPU-rig autopilot (yet). v1 targets the ADR-0012 v1 shape: test-gate and local/GitWorld domains plus the GPU-free rigs. Composite GPU rigs join when ADR-0042 gives admission a real quota to lean on. The T3 tier is this boundary made queryable: issues the ranker judges to need a multi-component live rig stay new, excluded from the pick behind a profile flag that flips when rig backends land — a visible, auditable queue instead of silent drops.

Workstreams

#WorkstreamBuilds on
O0crucible triage: tier ranking in-engine + the state/outer.sqlite schema (versioned like the session log) + the event loganalyze/ scripts, native issue fetch
O1The reconcile function + autopilot --once: pick → scope → park-or-approval → launch → record, with the four capsADR-0012 S2, check, selftest, deploy, ps
O2The daemon shell: work queue (dedup by key, backoff requeue, startup re-enqueue) + sources (timer, kube pod watch) + the Deployment render + profile schema (cadence, budgets, concurrency cap, state volume)ADR-0011
O3Approvals wiring: the ADR-0003 approval signal flips awaiting-approval rows; the review-comment watcher absorbs watch-pr and reseeds the next runADR-0003, ADR-0041, ADR-0012 S4
O4Fleet visibility: report queries the database (timeline, parked-row inbox)report, parquet sidecars

Slicing: O0 first — it is useful standalone the day it lands (a human running triage by hand is still better discovery than a human reading the issue tracker). O1 behind it, runnable by hand as --once long before O2 makes it a resident. O3 before the daemon runs unattended (the approvals must work end-to-end before reconciles happen while we sleep). O4 last.

Consequences

  • Positive: the research program runs while nobody is at the keyboard, and the human's job contracts to exactly the two judgment points (approve packs, review PRs) — the paper's "stay the engineer, not just the one who presses go." Every existing subcommand becomes load-bearing; none change contract; watch-pr stops being a separately-tended process. The database + event log give the program an audit trail, and crucible report reads SQLite the same way it reads the parquet sidecars, so the fleet page (O4) is a query, not a new format.
  • Negative / cost: unattended token spend is real even with caps (scope turns on issues that die in validation are the price of discovery); the triage heuristics will misrank — parked rows need periodic human sweeps or they rot into a graveyard; the controller is a new always-on ops surface (liveness probe, upgrades, a PVC for the database, a scoped GitHub token in the pod) — accepted because it consolidates lifecycles we already ran ad hoc; two approvals mean latency — an approval can sit for days, which is correct but makes "fully automated" honest-with-asterisks by design.

Alternatives Considered

A resident controller daemon with a work queue, SQLite working state, and an append-only event log (accepted)

A CronJob-tick design instead of a resident daemon (rejected)

  • Rejected because: Every interesting edge in this loop is event-shaped (approval granted, loop pod finished, review comment landed) and cron converts each into up-to-one-interval of latency; the cron design also didn't remove the daemons, it scattered them (watch-pr and the control bridge were already babysat separately).

A prose-keyword classifier as a tier-ranking fallback (rejected)

  • Rejected because: Built as a prior and then deleted: it misranked, every issue gets exactly one cached LLM verdict anyway (identical spend), and a wrong cheap tier is worse than an honest NULL.

ADR-0044: Orchestration state model — the controller's ledger

Status: proposed | Date: 2026-07-02

References: ADR-0043, ADR-0042

Context

Related: ADR-0043 (the controller this ledger serves), ADR-0004 (the precedent: state model as its own record), ADR-0010 (the candidates this tracks), ADR-0042 (rig leases, the named deferral), session.rs (the versioned append-only log discipline this leans on, and never replaces).

ADR-0043 gave the outer loop a controller daemon with SQLite working state. This ADR fixes the state model itself: what the database is for, what it may never become, and the schema's load-bearing decisions. The one-sentence version: the database is the control plane's queryable index; the append-only logs, git, S3, and GitHub remain the data plane's truth. A run's own history never moves into a database; anything that must be queried across runs or claimed transactionally never stays in a flat file.

The generalization this buys, beyond ADR-0043's issue tracking: the causal chain issue → scope → run → candidate → PR — today scattered across SCOPE.md, worktrees, FORGE_LATEST_FILE, RESULTS.md, and PR URLs buried in session events — becomes one provenance graph. Admission caps become ledger queries. Wide-round resume (today a killed wide round forgets its measured lanes) becomes "skip the candidates that already have scores." Rig leases (the wide/explore=one-shot, deep/exploit=warm-lease synthesis) become a lease table the day ADR-0042 needs one.

Decision

Truth model: the database is a rebuildable index

The DB is a materialized view over the canonical stores — session logs, S3 run records, GitHub issue/PR state. Every row carries a pointer to its evidence (a session-log URI, an S3 key, an issue/PR URL). crucible db rebuild reconstructs the whole database from those sources; if the DB and the evidence disagree, the evidence wins and the row is wrong.

Because the chosen migration policy (below) means the rebuild path is not exercised on every upgrade, it must be kept honest deliberately: the controller periodically rebuilds into a temp database and diffs it against the live one — drift detection and fallback-testing in one move.

The named exception, deferred: rig leases are control state that exists nowhere else — a lease is not derivable from any log. When ADR-0042 lands them, they become a documented split-authority island (or gain their own event stream to derive from). Deferring leases out of v1 keeps the truth model pure today; the tension is recorded here so it is a decision, not a surprise.

Ingest: the controller pulls; runs never know the database exists

The single-writer rule (one controller process, one node) means loop pods, brokers, and sandboxes never touch the DB. Outcomes flow in through the edges that already exist: the controller's pod watch sees a run finish, reads the run's session log / S3 record, and folds the outcome into rows inside its own transaction. This also ingests hand-launched runs for free — anything that publishes a record is visible to the pull path, whether or not the controller started it. A push endpoint (a run POSTing its exit report) is explicitly not v1; if mid-run row freshness ever matters, it arrives as a latency optimization on top of pull, never as a correctness dependency.

Granularity: per-candidate rows

One row per wide-round lane and per deep-loop iteration — score, decision, worktree, sandbox name, PR URL — not just a per-run summary. This is what makes the provenance graph complete and wide-round resume real. The session log already carries all of it per-candidate (row events), so the pull path has its source.

Topology: one database per controller

state/outer.sqlite, with repo/pack as columns, not as database boundaries. The DB's scope equals the process's scope equals the writer's scope — the global daily budget and the cross-repo inbox are single-database queries. Per-repo or per-pack databases were rejected: the outer loop is precisely the thing that spans them.

Migrations: real migrations, rebuild as disaster fallback

Versioned SQL migrations run on startup (sqlx's embedded migrations). Rebuild-from-evidence is the fallback for disasters and the periodic drift check above — not the upgrade path, so upgrades stay fast and offline.

Retention: keep everything

Rows are pointers and scalars, never payloads; the provenance graph is the product. SQLite is comfortable orders of magnitude beyond this workload. Revisit only if triaging a huge upstream tracker measurably bloats the file.

Overrides: an HTTP API on the controller, CLI first, UI later

Humans change state (unpark, bump priority, force-done) through a small HTTP API on the controller — the control-bridge pattern the inner loop already uses. The CLI (crucible triage park|unpark|bump …) is client #1; a web UI is client #2 of the same endpoints, already anticipated. Override writes go through the work queue like any other event, so they compose with claims instead of racing them. Writing the SQLite file directly is documented only as daemon-stopped surgery. Reads are unrestricted — the report generator and any read-only UI can open the file (WAL mode) or query over the API.

Technology: sqlx (sqlite), and async stops at the controller's edge

The controller is the codebase's one naturally-async component — kube watches, the override API, and now sqlx share one tokio runtime. The reconcile function itself stays synchronous engine code, entered via spawn_blocking; no other crate in the workspace grows an async dependency because of this choice. sqlx also supplies the embedded migrations above. Its compile-time query checking runs in offline mode (the checked-in .sqlx metadata), so builds need no live database.

Schema (v1 shape, illustrative)

repos(repo PRIMARY KEY, last_seen_updated_at)  -- the upstream-poll watermark ([ADR-0043](../adr/ADR-0043.md) §6)
issues(key PRIMARY KEY, repo, tier, status, priority, evidence_url,
       parked_reason, parked_by /* machine|human: only machine parks auto-unpark */, updated_at)
scopes(id, issue REFERENCES issues, pack_digest, check_outcome,
       stale /* upstream changed after freeze; surfaced at the approval gate */,
       approval_pr, approved_by, approved_at)
runs(run_id PRIMARY KEY, scope REFERENCES scopes, identity_digest, status, pod, session_uri, best_score, cost_usd)
candidates(run_id REFERENCES runs, kind, lane, iter, score, decision, worktree, sandbox, pr_url)
ledger(ts, run_id, kind, cost_usd)          -- admission caps are SUM() queries over this
_sqlx_migrations(...)                        -- schema version

Growth points, designed-for but not built: a durable queue table with lease columns (the day one worker is not enough), leases(rig, holder_run, mode, expires_at) (ADR-0042), and the whole schema ports to Postgres wholesale if multi-node ever becomes real — nothing above the storage layer changes.

What this is NOT

  • Not the inner loop's state. A crucible --manifest … run never opens a database; the session log + git remain a run's complete, self-contained record. Local-first stays intact.
  • Not a message bus. Cross-pod communication does not go through SQLite (a shared-volume SQLite is a corruption story). Pods report through logs/S3/exit status; the controller folds.
  • Not precious. Back it up if convenient; its loss is a rebuild, not an incident — leases, when they exist, will be the one flagged exception.

Consequences

  • Positive: the orchestration layer gains transactions, joins, and an audit trail without a database server; wide-round resume and full provenance drop out of the granularity choice; crucible report queries live state (DuckDB reads SQLite directly) next to the parquet sidecars; the override API is the UI's foundation, already load-bearing for the CLI.
  • Negative / cost: the pull-ingest path must parse session logs robustly (it becomes a second consumer of the log schema — version discipline now has two customers); sqlx makes the controller an async island to maintain; the rebuild path is only as good as the periodic drift check we actually run; per-candidate granularity means the pull path does real work per run, not one upsert.

Alternatives Considered

One SQLite database per controller, a rebuildable index over the canonical stores (accepted)

Per-repo or per-pack databases (rejected)

  • Rejected because: The outer loop is precisely the thing that spans them; the global daily budget and the cross-repo inbox need to be single-database queries.

A push endpoint (runs POSTing their exit reports) instead of controller-pull ingest (rejected)

  • Rejected because: Explicitly not v1: anything that publishes a record is already visible to the pull path; if mid-run freshness ever matters it arrives as a latency optimization on top of pull, never a correctness dependency.

ADR-0045: InputKind — non-upstream issue sources (adopted scenarios)

Status: accepted | Date: 2026-07-12

References: ADR-0043, ADR-0044

Context

Related: ADR-0043 (triage/autopilot; InputKind is the source-shape this discovery pipeline was built around), ADR-0044 (the issues table this adds a discriminant column to), ADR-0012 (the --issue/--goal-file ingest arms a scenario's goal text rides).

crucible-controller discovers work from one source: GitHub issues. Every reconcile gate — rank horizon, tier ranking, grounded prescope, daily/scope caps, the draft-PR approval gate — assumes a live upstream item to fetch freshness, priority, and a close/comment channel from. A human who wants the loop to chase a free-text problem statement (no GitHub issue exists yet, or never will) had no path in.

Decision

Issue gains a closed-but-growing InputKind enum (GitHub { .. } / Scenario { id } / Unknown { tag }, the last an inert forward/backward-compat fallback in the ParkReason::Legacy verbatim-fallback style), persisted as a issues.input_kind discriminant column (0021_input_kind.sql, additive, existing rows backfill to github). Two capability predicates — has_upstream(), accepts_pr_backlink() — replace every GitHub-shaped assumption in reconcile with a match-free branch, so a future source (Jira, a forge) that has an upstream but no PR backlink, or vice versa, isn't forced into either extreme.

A non-upstream issue's human adoption is its tier/priority authorization: reconcile_new branches on !issue.kind.has_upstream() immediately after the existing ScopeNow fast-path and routes straight to the scope turn, bypassing the rank-horizon gate, confirm_tier, grounded prescope, and the daily/scopes-per-day caps — the same bypass shape reconcile_scope_now already has for a human-triggered GitHub issue, expressed as a capability predicate instead of a stash flag so it survives pod re-drives idempotently. The goal text is ledgered free form at adoption (scenarios sidecar table) and flows to the engine via --goal-file, never --issue, under the same de-prescription rules as any GitHub issue body (see docs/crucible-contract.md §9). A kind with no PR backlink skips the draft-PR approval gate entirely; the UI's own approve endpoint (POST /api/scenarios/{key}/approve) stamps approved_at directly, reusing the run-launch gate unchanged.

POST /api/scenarios is admin-gated and ledgered like every other human-authorized mutation. It targets any resolvable git URL/path at adopt time, not the repos watch-set — a scenario's subject repo is a one-off pick, not a swept source.

Consequences

  • Every GitHub-ism in reconcile (comment refresh, stale/closed parks, pr_url publish-back, the draft-PR approval) degrades to a no-op for a non-upstream kind instead of wedging the row; Unknown is inert everywhere by construction, so a corrupt or forward-rolled tag can't bail the sweep.
  • Existing GitHub issues are unaffected: upsert_issue's INSERT omits input_kind, picking up the github default, so the live ingest path needed zero changes.
  • A future upstream-but-no-PR-backlink source (or the reverse) is one more InputKind variant and two predicate answers, not a second reconcile path.

ADR-0046: Hub-spoke delegated jobs, per-target remote clusters for broker work

Status: accepted | Date: 2026-07-30

Context

Related: ADR-0005, ADR-0011 (secrets by name only), ADR-0014, ADR-0016, ADR-0017.

Every delegated job the broker runs today lands on exactly one cluster: its own. The codegen measure/profile Jobs (crucible-broker/src/codegen.rs), forge's cluster builds (forge/src/build.rs::dispatch_cluster), and the deploy hooks all route through the single client in forge/src/kube.rs::client(), which is Client::try_default(): the in-cluster SA when in a pod, else the ambient kubeconfig. Substrate facts (namespace, Kueue queue, PVCs, GPU ceiling) arrive as BROKER_CODEGEN_* env projected from the profile's [measure] table (crucible/src/deploy/profile.rs::MeasureSubstrate::broker_env). "Which cluster" is not a parameter anywhere.

That stops working now that the GPUs are not where the loop runs. The hub (the MP+ cluster) sits behind VPN with no GPU headroom; the first spokes are an H200 cluster with a public API and a B200 box with no S3 whose API is reachable only through an SSH bastion. Two existing properties make delegation cheap: job results ride the sentinel log path (wrap_command / parse_result_json), so metrics need no shared storage, and log collection is poll-based monotonic snapshots (forge/src/measure_job.rs), where a failed snapshot just skips a poll. The status poll is the exception: wait_for_job (forge/src/kube.rs) propagates any api.get() error out of the loop, so one transient failure on a far path (tunnel reconnect, WAN blip) would abort a measure whose GPU job keeps running. Cross-cluster requires bounded retry on the status poll; see §4.

Decision

Delegated jobs only gain a per-target cluster. The loop pod, its sandboxes, and the broker stay where they run today. Candidate builds also stay hub-side in v1: spokes pull candidate images from the registry like any other image; spoke-side builds are future work.

1. Substrate addressing

The profile's [measure] table gains an optional cluster = "wharf". The [clusters.<name>] tables live in a fleet file resolved by one rule: clusters.toml in the profile's own directory (profiles are explicit paths handed to crucible deploy render|apply, crucible/src/deploy/mod.rs, so for deploy/deepgemm/profile.wharf.toml the fleet file is deploy/deepgemm/clusters.toml), overridable with an explicit --clusters <path> flag. A profile may override or add a [clusters.<name>] block locally, profile winning on conflict.

# deploy/<domain>/clusters.toml
[clusters.wharf]
kubeconfig_secret = "spoke-wharf-kubeconfig"   # scoped SA kubeconfig, see §2
# host_aliases = { "api.spoke.example" = "10.0.0.1" }   # DNS-dark tier, §3
# proxy_url = "http://10.0.0.9:3128"                     # proxy-reachable tier, §3a
# [clusters.<name>.bastion] host/user/key_secret        # ssh-bastion tier, §3b

The renderer mounts the named secret at a fixed path and projects BROKER_CODEGEN_KUBECONFIG; forge::kube::client() grows an explicit-kubeconfig path (per-call target, not a global) beside try_default(). No cluster key means no new env and the try_default() path, so every existing profile keeps its exact behavior. Forge build jobs and deploy hooks address a cluster the same way.

2. Credentials: hub-push

The broker holds the spokes' credentials, never the reverse. Each spoke admin mints a namespace-scoped SA whose Role covers only job submission and observation (Jobs create/get/delete, pods/log, Kueue Workload reads), exports a kubeconfig, and stores it as a hub Secret the fleet file references by name. The kubeconfig must embed a long-lived Secret-based SA token (a kubernetes.io/service-account-token Secret), not kubectl create token: bound tokens default to 1h and expire mid-job and mid-log-poll. Spokes fronted by an interactive OAuth IdP (GitHub) bootstrap via one human login to create the namespace, SA, and RoleBinding; automation never touches the IdP flow. Vault later owns token rotation; the Secret names do not change.

The spoke kubeconfig Secret mounts on the loop pod, which shares a namespace with agent sandbox pods (the openshell kubernetes driver). The isolation claim, the broker holds spoke credentials and the agent never does, therefore requires that the sandbox SA has no Secret read in that namespace; the deployment RBAC must not grant it and crucible check validates it (a necessary but not sufficient probe; see the residual risk under Consequences).

3. Reachability tiers

  1. Public (wharf): the kubeconfig alone.
  2. Routable but DNS-dark: host_aliases renders as pod-spec hostAliases on the loop pod (precedent: the openshell kubernetes driver already injects hostAliases on sandbox pods via host_gateway_ip, see POD_IP_ENV in crucible/src/deploy/render/kube.rs). Declarative, TLS intact because the hostname is preserved. The alias is baked at render time, so a spoke API IP change fails at connect until a re-render; the per-cluster crucible check probe (Risk section) is the detector.
  3. Non-routable, in two flavors:
    • 3a, proxy-reachable (the pokprod class): the spoke API is reachable through an HTTP CONNECT proxy (squid, e.g. http://10.x.x.x:3128) and needs no tunnel. The cluster entry's optional proxy_url field feeds kube-rs's native proxy support (the http-proxy feature, Config::proxy_url; kubeconfig proxy-url also flows through, the fleet-file field winning on conflict). Pure config plus one client-construction branch; TLS to the API server is end-to-end through the CONNECT tunnel.
    • 3b, ssh-bastion (the B200 box): a bastion block, served by an in-broker in-process SSH tunnel using the russh crate (pure Rust, verified 0.62.4: tokio-based, default crypto backend aws-lc-rs; Cargo.lock already carries aws-lc-rs 1.17.0 and forge installs it as the rustls 0.23 CryptoProvider, forge/src/kube.rs). The broker opens a direct-tcpip channel to the spoke API server and pipes a local TCP listener over it; the spoke kubeconfig targets https://127.0.0.1:<port> with tls_server_name set to the real API hostname, so certificate verification against the spoke CA is intact (verified: kube-client 4.0.0 exposes tls_server_name on both Config and the kubeconfig Cluster). No ssh binary, no child process; reconnect-with-backoff on channel errors is the supervision. Channel errors alone do not catch a NAT/idle-timeout-killed tunnel, which dies silently and leaves in-flight requests hanging on kernel TCP timeouts, so the client config sets russh's keepalive_interval/keepalive_max and the tunneled kube client gets a per-request timeout. Fallback ladder if russh disappoints: the openssh crate (0.11.6, async wrapper over the system binary), then autossh.

4. Execution, artifacts, failure

Job semantics are unchanged: suspended submit, Kueue admission, activeDeadlineSeconds, ttlSecondsAfterFinished. The status poll (wait_for_job) gains bounded retry on transient api.get() errors so a reconnect window or WAN blip does not abort a measure whose GPU job is still running; the overall job deadline stays the backstop. Remote jobs self-reap if the hub broker dies mid-job; the one gap is a Job still suspended in queue, so the broker sweeps its own leftovers per spoke at startup. Because suspended is the normal state for anything waiting on Kueue admission, managed-by=crucible alone would reap a sibling broker's queued jobs; job labels therefore gain a per-broker instance id and the sweep selects on it, so a broker only ever reaps jobs it submitted itself. Results ride the sentinel log path, so the S3-less spoke needs nothing. Profile traces are the exception: collect_trace_artifact reads the artifacts PVC as a local mount, which does not exist cross-cluster. v1 keeps the size-capped base64-through-logs fallback on remote spokes; the follow-up is an API-mediated collector pod on the spoke, which is also the retrieval half of the publish-on-keep PVC results backend.

An unreachable spoke is a typed tool error, never a hang: bounded connect timeout, and the agent sees {"status":"error"} naming the spoke and its tier. The MCP trace span and the run ledger's measure rows gain a cluster field, so a cross-spoke result is never mistaken for a hub-local one.

Non-goals

Remote sandboxes and remote loop pods; spoke-side builds; Vault integration; multi-spoke scheduling policy; spoke-pull execution.

Consequences

  • Positive: GPU capacity decouples from where the loop runs; one addressing vocabulary for measure, build, and deploy delegation; existing profiles untouched; no child processes.
  • Cost: per-spoke SA lifecycle is manual until Vault; profile traces on remote spokes regress to the size-capped fallback until the collector lands.
  • Risk: a stale spoke kubeconfig (or stale host_aliases IP) fails at job submit, not at render; a crucible check probe per named cluster is the mitigation. A bound SA token minted via kubectl create token 401s partway through a multi-hour rung; the bootstrap procedure mandates a long-lived Secret-based token for exactly this reason.
  • Residual risk (sandbox-SA isolation): the crucible check SAR probe (secrets get/list/watch) is necessary but not sufficient. Sandbox pods currently run as the loop SA, and any SA with pods/create in the loop namespace can mount the spoke-kubeconfig Secret into a pod of its own with no secrets RBAC at all. The real boundary is that sandbox pods must not run as an SA with pods/create in the loop namespace, and must not automount that SA's token. Follow-up: a dedicated no-permission sandbox SA, or a separate sandbox namespace.

ADR-0047: Controller MCP run logs: pull cursor with a local spool

Status: proposed | Date: 2026-08-28

References: ADR-0042, ADR-0048, ADR-0049

Context

An agent driving the controller through MCP cannot see what a run is doing. The only observation available is polling a status field on crucible_playbook_run or crucible_turns for a terminal flip, so a run is opaque for its whole duration and the agent learns nothing until it lands. The evidence does not survive either: run pods are deleted promptly after collection, so oc logs is already NotFound minutes after a run finishes, and the live stream is the only capture window. The controller itself is not the gap. It already relays run output over SSE with a monotonic seq and Last-Event-ID resume; that stream simply has no consumer on the MCP side.

Three constraints shape the answer. MCP has no streaming tool result, so a tool returns once and any incremental delivery has to be modelled on top. Agent result budgets are small and hard-capped, so a long log tail cannot be an ordinary return value; a 235KB read on this same surface already exceeded the cap and was spooled to a file instead of answering. And the MCP runs in two shapes: local stdio, where the server shares a filesystem with the agent, and remote HTTP, where it does not.

ADR-0042 already faced this for the broker and chose an on-demand pull cursor, rig_logs(run_id [, cursor]) -> { chunk, next_cursor }, explicitly so a long log tail never floods the agent's context, deferring SSE progress until the stateful tool-discovery regression is fixed. That precedent constrains this decision but does not settle it, because the local deployment has a capability the broker path did not assume: a shared filesystem, and an agent that already owns competent tools for reading files.

Decision

The controller MCP will expose run logs as a handle, never as a payload, in two layers over the existing SSE relay.

The portable layer is a pull cursor carrying the shape ADR-0042 already accepted for the broker: a bounded window plus the cursor to resume from. It is the floor, works in every deployment shape, and inherits its ordering and resume semantics from the relay's seq rather than defining a second cursor.

The local layer is a spool. Where the MCP shares a filesystem with the agent, a background consumer in the server process subscribes to the run's stream and appends it to a file as one JSON object per line; the tool returns the path immediately and the agent reads it with the tools it already has. This wins because the reading problem is already solved outside the tool surface, and because the spool is what turns a live stream into evidence that outlives the pod.

The cursor is the contract and the spool is an optimization over it, not a fork. Both refuse to put log bytes in a tool result.

Consequences

A run stops being opaque while it runs, and its output stops being lost when it ends. Because pods are deleted shortly after collection, capturing the stream live is the only way log evidence survives at all, and the spool makes that the default rather than something an operator has to think to do in advance. Delegating reading to the agent's own tools keeps follow, filter and search out of the tool surface entirely.

The costs are real. Two delivery paths must stay behaviorally consistent, and the spool brings a lifecycle the tool surface did not previously have: size caps, rotation, and cleanup, so a runaway pod cannot fill the agent's disk. The spool also makes the server process stateful, holding a background subscriber per watched run; that state is invisible to the client, which still only ever pulls a path, so the stateful tool-discovery regression ADR-0042 defers on does not apply to the stdio deployment, but it does bound how far this generalizes to a remote HTTP MCP. Keeping the pull cursor as the floor is the mitigation.

The design is blocked on a relay fix rather than being independent of it: only session events currently carry a seq, so resume protects the session stream and not plain log lines. Extending seq to every event kind is a precondition, not a follow-up. Neutrally, this leaves the controller with two cursor consumers over one relay, so the seq becomes a contract the relay owes its subscribers rather than an internal detail. The concrete tool names, cursor type, and spool location remain open and belong to the implementing work.

Alternatives Considered

Pull cursor as the portable contract, plus a local spool the agent reads with its own tools (accepted)

  • Pros: One contract works in both deployment shapes: the cursor is the floor everywhere, the spool an optimization where a shared filesystem exists, The spool captures the stream while the run is live, which is the only window before the pod is deleted, Reading is delegated to tools the agent already has, so tail, grep and follow behavior need no equivalent in the tool surface, Neither path puts log bytes in a tool result, so a long tail cannot exhaust the agent's result budget
  • Cons: Two delivery paths to build and keep behaviorally consistent, The spool needs a lifecycle of its own: size caps, rotation, and cleanup the agent does not have to think about, Depends on the relay assigning a seq to every event kind, which it does not do today

Pull cursor only, mirroring the broker's rig_logs exactly (rejected)

  • Pros: Exactly the shape ADR-0042 already accepted, so no new precedent and no new client assumptions, Transport-agnostic, and stateless from the client's point of view
  • Cons: Following a live run means the agent repeatedly calling a tool, spending a turn per poll on output it usually discards, Discards the local deployment's shared filesystem and the agent's own file tooling for no benefit
  • Rejected because: It is the right floor but a poor ceiling: polling a cursor turn by turn is how an agent burns context to watch a quiet log, and on the local path the file the agent could have tailed already exists.

Local spool only, with no pull cursor (rejected)

  • Pros: Simplest to build: one background consumer, one file, no cursor arithmetic, Best ergonomics on the local path, where the agent's own tools do the reading
  • Cons: Silently unusable over remote HTTP, where the written file is on a host the agent cannot read
  • Rejected because: It makes log access a property of how the MCP happens to be deployed, so the same tool call succeeds locally and returns an unreadable path remotely.

Stream progress through MCP progress and logging notifications (rejected)

  • Pros: Purpose-built for incremental server-to-client delivery, and already part of the protocol
  • Cons: Carries status text, not a queryable transcript: nothing is addressable or greppable after it scrolls past, Every frame costs agent context whether or not it is relevant, which is the flooding ADR-0042 set out to avoid, Rendering is the client's choice, so delivery cannot be relied on
  • Rejected because: Progress notifications answer how far along a run is, not what it printed, and they spend context on arrival rather than on demand.

Expose the log as an MCP resource and let clients subscribe to updates (rejected)

  • Pros: The protocol's own model for a changing document, with subscription and update notification built in
  • Cons: An update notification says only that the resource changed, so a client re-reads the whole log to learn what was appended, Client support is uneven, and unsupported subscription degrades to no updates at all
  • Rejected because: Diffless change notification over an append-only log means quadratic re-reading of the exact payload that must not enter the agent's context.

Return the log body inline in the tool result (rejected)

  • Pros: No new concepts: one call returns the logs
  • Cons: A real run's tail exceeds the agent's result cap, so the call fails to answer exactly when the logs matter most
  • Rejected because: Already demonstrated on this surface: an unbounded read returned 235KB, blew the result cap, and was spooled to a file, arriving at the handle-return design by accident and after the fact.

ADR-0048: Hub-spoke pod dispatch, the controller runs loops on spoke clusters

Status: accepted | Date: 2026-07-30

References: ADR-0046, ADR-0036

Context

Related: ADR-0011, ADR-0015, ADR-0046.

ADR-0046 delegated broker jobs to spoke clusters and declared remote loop pods a non-goal. This ADR takes that on. The controller is live on the hub (crucible--runtime-ext, behind Keycloak) with dispatch parked and an internal-shard route only, unreachable from the GPU cloud. The GPUs, the loop namespaces, and the sandbox substrate are on wharf. v1 dispatches loop and turn pods from the hub controller to a single spoke (wharf); multi-spoke stays structurally possible (cluster column, target-aware dispatcher) but is not configured.

The constraints that shape the design, all probe-verified:

  • the hub's SA issuer is https://kubernetes.default.svc (the default, shared by every the hub tenant), its JWKS is not anonymously served (403 on VPN), and the hub API is unreachable from wharf pods. Standard OIDC discovery cannot work; trust must bootstrap another way.
  • Wharf API-server auth is the github-authn-webhook (~/git/llm-d-downstream-infra, tools/github-authn-webhook, Rust): a multi-provider OIDC webhook authenticator with per-provider issuer/audiences/claimMappings/validationRules. It derives the discovery URL from the issuer string (src/jwt.rs:50-94) and ProviderConfig (src/config.rs:17-30) has no discovery_url override. Adding one is the enabling change (separate repo PR).
  • The controller holds 8 independent Client::try_default() sites (kube_dispatcher.rs:19,40, daemon.rs:281, live.rs:271, turn_live.rs:148, approvals.rs:771, overrides_store.rs:994, lib.rs:175); work_pods has no cluster column, so startup reconcile cannot attribute pods to clusters. Pod specs are already profile-driven (rendered via crucible deploy render-turn, namespace from profile.cluster.loop_namespace); only the API call targets are hardcoded.

Decision

1. Identity: workload identity federation, kubeconfigless

The controller authenticates to wharf with a projected, auto-rotating SA token carrying a dedicated audience. Wharf's github-authn-webhook gains an optional discovery_url per provider plus an hub-crucible provider block. Because the issuer string is shared by every the hub tenant, the audience and a sub allowlist (system:serviceaccount:crucible--runtime-ext:crucible-crucible-controller) carry the entire trust. Zero stored credentials on either side.

The controller serves a read-only OIDC discovery mirror, /.well-known/openid-configuration and /openid/v1/jwks, live-proxied from its own cluster API using its SA. This exists because the hub's issuer is not publicly discoverable and the hub's API is unreachable from wharf; the webhook's discovery_url points at the mirror instead of the issuer.

2. Exposure: external router shard, two endpoints

A second route on the external router shard (the WAF shard only accepts traffic from the WAF itself, which spokes cannot ride) exposes exactly the ingest drop-box and the discovery mirror. The SPA stays on the internal shard. This puts the drop-box on the internet for the first time; the layers are the WAF shard, the TokenReview check, and the bound-pod claim (a token is only accepted for the pod it names). WAF rate limiting on the drop-box route is required route config, not optional hardening: every invalid-token request otherwise costs a TokenReview against the spoke API (see §3).

3. Ingest: per-spoke TokenReview through the federated client

The drop-box (POST /api/pods/{pod}/artifacts/{kind}, ingest_drop.rs:57, outside oauth2-proxy) today validates via TokenReview on the controller's own cluster (lib.rs:175-181). Wharf tokens must be reviewed against wharf: IngestValidator gains a cluster-keyed validator map, pod rows carry their cluster, and each token is reviewed by the matching cluster's federated client. Each map entry carries its own expected namespace and ServiceAccount: the current single global pair (pod_namespace + turn_service_account) cannot validate wharf pods, whose loop namespace differs from the hub's. Pod-name binding is preserved. When a spoke API is unreachable the check fails closed with 503. The federated identity holds tokenreviews create on wharf (ClusterRole) for this.

The accept cache stores accepts only, so an internet flood of invalid tokens would translate one-for-one into TokenReviews against the spoke API. The validator adds short-TTL negative caching for rejected tokens, paired with the required WAF rate limit from §2.

Result delivery otherwise survives unchanged: tier 1 is a pods/log pull through the kube API (reconcile/completion.rs:298), which works cross-cluster through the federated client. The ingest URL is a plain profile string ([cluster].ingest_url, profile.rs:273-278) projected as CRUCIBLE_INGEST_URL; pointing spoke pods at the external hub route is a one-line profile change.

4. Live view

Turn-live (API log streaming, turn_live.rs) works cross-cluster. The run-live raw-TCP relay to podIP:7777 (live.rs:143) is dead cross-cluster and stays hub-only in v1; remote runs keep the turn-live view and SSE logs. Portforward-over-API is the named follow-up.

5. Client architecture

One shared reconnecting client per target cluster replaces the per-call try_default() rebuilds. It is built via a new pub async constructor on forge's KubeTarget (forge/src/kube.rs:43-110, today private and sync-wrapped); fleet/cluster types (ClusterEntry and friends) sink from crucible/src/deploy/profile.rs into forge next to KubeTarget, since the controller cannot import crucible. The federated client's config must set auth_info.token_file rather than loading the projected token into a string at construction: kube-client re-reads the file and refreshes at least once per minute (kube-client 4.0.0, src/client/auth/mod.rs:173), which is what makes rotation work. The PodDispatcher trait (workpod/dispatcher.rs:15-33) is the target-aware boundary; work_pods gains a cluster column so startup reconcile and the sweep can attribute pods. The bounded-retry transient-error classification from hub-spoke S2 is applied to await_terminal (kube_dispatcher.rs:50-84), startup reconcile, and the sweep before any WAN exposure, so a blip neither aborts a live run nor reaps an unseen pod.

RBAC for the wharf controller identity: pods get/list/watch/create/delete, pods/log get, configmaps create/delete and secrets create/delete (both namespaced; the secrets verbs carry the per-run mounted Secret of ADR-0036), plus tokenreviews create (ClusterRole). Sandbox-driver grants remain pod-identity grants on wharf, unchanged. The chart keeps rendering one turn-profile ConfigMap; the profile continues to describe wharf, only the dispatch target changes.

Non-goals

Multi-spoke configuration (structure only); run-live raw TCP for remote pods; spoke-side controller components; portforward-over-API relay.

Consequences

  • Positive: loops run where the GPUs are with zero stored credentials; token rotation is the kubelet's job; multi-spoke is a config change, not a refactor.
  • Cost: the drop-box becomes internet-facing (mitigated per §2); run-live view lost for remote pods until the portforward follow-up; a webhook change in a separate repo is on the critical path.
  • Risk: the discovery mirror is a single point of trust bootstrap; if the controller is down, wharf cannot validate new tokens (cached JWKS covers short outages). A shared issuer string means the sub allowlist is load-bearing; the webhook config review must treat it as a security boundary.

ADR-0049: Controller high availability, leader election and the shared-state prerequisite

Status: accepted | Date: 2026-08-19

References: ADR-0043, ADR-0044, ADR-0048

Context

The controller runs as exactly one pod: replicas: 1 with a Recreate strategy, because the ledger was SQLite on a ReadWriteOnce PVC and two writers would corrupt it. Moving the ledger to Postgres removes that specific justification, so "why not run N replicas" becomes a fair question. The answer today is still no, for two reasons that are worth separating: one is a correctness invariant that survives the Postgres move, the other is a storage layout that does not have to.

The invariant that stays: at most one reconciler

Postgres makes concurrent statements safe, not concurrent decisions. The status transitions are already race-proof (claim_issue is a single conditional UPDATE, so two racers cannot both win a claim). The admission caps are not: every one of them reads a count and then acts on it, which two reconcilers pass simultaneously.

  • reconcile/tier.rs:74, reconcile/scope.rs:43, reconcile/scope.rs:113, reconcile/lifecycle.rs:78: ledger_day_total(...) >= daily_cost_ceiling.
  • reconcile/scope.rs:118: scopes started today against max_scopes_per_day.
  • workpod/run.rs:304: active >= max_concurrent_pods.
  • workpod/sweep.rs:232: active turn pods against the per-kind cap.

What leaks through a lost race here is money and GPU time, not a corrupt row, which is why the guard has to be structural rather than advisory. Two reconcilers also duplicate every side effect the caps exist to bound: the discovery timer, the three approval polls, the kube pod-completion watch, and the scheduled drift check (daemon.rs:128,151) each fire once per replica, so PRs get double-commented and pods double-dispatched.

The conclusion is that N>1 means N-1 standbys plus one active reconciler, never N concurrent reconcilers. Making reconcile itself concurrency-safe (per-issue row locks, a claim-based work queue over FOR UPDATE SKIP LOCKED) is a much larger change with no demand behind it: the workload is a handful of issues per minute, bounded by LLM spend, not by controller throughput.

The blocker that does not have to stay: the ReadWriteOnce volume

persistence.accessMode: ReadWriteOnce (values.yaml:282) mounted at /var/lib/crucible/state (deployment.yaml:343). Two pods cannot mount it across nodes, so even a RollingUpdate deadlocks: the new pod blocks on a volume the old pod still holds. That is the direct cause of the Recreate strategy, and it means today's "failover" is a kubelet restart, tens of seconds of downtime with no standby.

The volume is not just the event log. Live inventory from crucible--runtime-ext on hub-prod:

PathWritten byNature
controller-events.jsonl (101 MB)event_log.rs:125append-only history, replay evidence
evidence/, runs/, adopted/ingest + adopt pathsrun artifacts, immutable once written
packs/scope turnspack markdown, read by the journey + API
checkouts/engine.rs:119git working copies for rank-compare
autopilot.jsonautopilot_flag.rs:45one runtime-mutable control flag
flow-cache/flow endpointderived cache, regenerable

Each of these has a different right answer, and only some of them need shared storage at all.

The event log is also a durability gap

transition() writes the row and then appends a line to the file. Those cannot be one atomic act across a database and a filesystem, so a crash in between leaves the ledger and its history disagreeing. Rebuild treats the event log as the authority for replayed status (rebuild.rs, replay_events), which makes the gap load-bearing rather than cosmetic. This is worth fixing on its own merits, and it happens to also be the largest single item on the volume.

Why not object storage for the artifacts (amendment context, 2026-08-19)

The first draft sent evidence/, runs/ and packs/ to object storage with a ReadWriteMany volume as the no-S3 fallback, and called that dependency out as the long pole: the hub has no provisioned object storage, and ReadWriteMany is a platform capability we never confirmed. Both problems dissolve if the bytes live in the database the controller already requires. The payloads fit the pattern: write-once, gzipped, sha256'd on ingest, and capped by crucible-contract (scope-pack 16 MiB, scope-transcript 32 MiB, run-session 128 MiB, otel-log 32 MiB). The pointer table already exists: pod_artifacts stores (pod, kind, path, digest, bytes) precisely so the ledger row is a pointer at the evidence. Storing the payload beside the pointer removes the second storage system instead of adding one, and Aurora's backups and point-in-time recovery are strictly better durability than an unreplicated 5Gi PVC. The read paths are cold and whole-file (adopt-on-completion, MLflow export, rebuild), so the usual argument against database blobs, hot random access to large values, does not apply here.

Decision

1. Leader election: a Kubernetes Lease, fenced by a Postgres advisory lock

A coordination.k8s.io/v1 Lease in the controller's namespace elects the active replica. Standard k8s semantics (holderIdentity, leaseDurationSeconds, renew on an interval, take over on expiry), observable with kubectl get lease, and unaffected by a database blip.

Election alone is not sufficient. A lease has the well-known stale-leader hole: a replica that stalls past its expiry still believes it leads and keeps writing, and what it writes here is spend. The active reconciler therefore also holds a session-scoped pg_try_advisory_lock on a dedicated connection, and treats losing it as an immediate step-down. The lock is the fence; the lease is the coordination.

The lock guards writers Kubernetes cannot see, which is the second reason to have it. Both db rebuild (which issues ALTER DATABASE ... RENAME) and db import-sqlite are today protected only by a doc comment saying to stop the daemon first. They take the same lock and fail fast, turning a documented invariant into an enforced one. A laptop pointed at the production DATABASE_URL gets the same treatment.

The lease is hand-rolled against the existing kube 4.0 client rather than taken as a dependency. The three candidate crates (kube-lease-manager, kube-leader-election, kubert's lease module) each pin their own kube generation, and a second kube in the tree is precisely what tools/check-dep-dups.sh exists to reject. A Lease is CRUD on one object.

This section stands alone and is worth shipping before any storage change: it converts maintenance-command safety from convention to enforcement, and it is the half of HA that has to exist before replicas can ever be raised.

2. The event log moves into Postgres

An append-only events table replaces the JSONL file as the system of record, preserving the frozen line shape exactly (it is rebuild evidence, so its schema is a compatibility surface, not an implementation detail). The append joins the state transition in one transaction, closing the durability gap above. Replay becomes a query rather than a file scan, and live SSE subscribers read the table instead of tailing a file, which is what lets a standby serve the live view.

The file does not disappear as a format: an export path keeps producing the same NDJSON for offline analysis and for the disaster case where the database is the thing that is gone.

3. Artifact and pack bytes move into Postgres; caches stop being persistent

(Amended 2026-08-19. Supersedes the object-storage + ReadWriteMany plan.)

  • evidence/: the payload moves into the database beside its existing pointer. Bytes are stored gzipped, exactly as ingested, chunked into an artifact_chunks(artifact_id, seq, data bytea) child table rather than one value per artifact: the 128 MiB run-session cap is too large to materialize as a single bytea on both ends of every read, and chunking keeps per-row TOAST and WAL amplification bounded. The drop-box keeps its contract: streamed ingest, inline sha256, per-kind cap enforced mid-stream, at-least-once dedup on the (pod, kind) key. db rebuild re-derives the ledger from these rows instead of the evidence directory; the database becomes canonical for evidence, and the rebuild-from-disk story is retired deliberately rather than silently.
  • runs/<id>/session.jsonl, adopted/: same treatment. Sessions are stored gzipped and gunzipped by the two whole-file readers (ingest fold, flow render). No streaming read path exists today and none is added.
  • packs/: the durable form becomes the gzipped tarball the pod executor already ships (scrape already receives packs as base64 gzip tar). Working trees are materialized to scratch when git, gh or the engine need one, and discarded after. The STEER.md reseed-append, today the one human-driven mutation of on-disk state, moves to its own pack_steering table and is injected into the tree at materialization, so the frozen pack stays frozen.
  • checkouts/, flow-cache/: per-pod scratch. Both are derived and regenerable, so they move to an emptyDir and stop being shared state at all.
  • autopilot.json: one flag, already mutable at runtime through POST /api/autopilot. It becomes a row. Its original rationale (a file survives db rebuild) inverts once the database is canonical for everything else, and N>1 requires the flag to be shared anyway.

No Flatbuffers or other binary serialization layer: the payloads are already gzipped NDJSON and tarballs, the one schema'd stream (events) becomes columns, and zero-copy decode buys nothing after a gunzip.

4. A one-shot migration tool moves the live volume

crucible-controller db migrate-state walks an existing state directory and loads it into the tables above: evidence files into chunks with digests re-verified against pod_artifacts, session logs and adopted runs likewise, packs re-tarred with any appended STEER.md steering split into pack_steering, controller-events.jsonl into the events table, and autopilot.json into its row. It is idempotent (digest match short-circuits a re-copy), refuses to run against a daemon holding the §1 advisory lock, and reports what it skipped. The cutover is: stop daemon, run tool, deploy the release that reads Postgres, then drop the PVC once a db rebuild --verify passes against the migrated data.

5. Only then: replicas > 1

With §1 through §4 done, the deployment drops its ReadWriteOnce PVC, moves to RollingUpdate, and raises replicas. Standbys are not idle: they serve the read API and the SPA, which makes the UI highly available independently of reconcile. Write endpoints split by what they do. Single-statement writes (repo add, override set) are safe on any replica because Postgres makes them atomic. Endpoints that dispatch work (the human scope trigger, which deliberately bypasses the caps because a human authorized it) are leader-gated and forwarded, because they are reconcile decisions wearing an HTTP hat.

Non-goals

Concurrent reconcile (N active writers, per-issue locking, work-stealing). Sharding issues across replicas. Multi-region or active-active. Automatic failover of the spoke dispatch identity, which is unchanged and per-cluster. Postgres HA itself, which is the database provider's problem (Aurora already does it). Retention policy for evidence and events: moving the bytes makes DELETE WHERE possible, but choosing the window is a separate decision.

Consequences

  • Positive: failover becomes a standby takeover rather than a kubelet restart; rolling updates become possible, so a deploy stops being an outage; the maintenance commands become safe by construction; the state transition and its history become one atomic act; the UI stops sharing a failure domain with reconcile; the deployment loses its only PVC and with it the storage-class, appcode and CMDB friction of provisioning one; every artifact inherits Aurora's backup and point-in-time-recovery posture.
  • Cost: database size grows by the artifact volume (gzipped, and boundable once retention exists, but on Aurora storage that is billed). Large-value writes amplify WAL; chunking bounds the per-row cost but the total is what it is. db rebuild changes meaning: it re-derives the ledger from evidence rows, so losing the database now loses the evidence too, and the NDJSON export in §2 plus Aurora PITR are the compensating controls. The event log's frozen shape has to survive the move intact or rebuild silently degrades.
  • Risk: the fence's correctness depends on the advisory lock being session-scoped on a connection that is not pooled in transaction mode. Aurora is addressed directly today, but putting RDS Proxy or pgbouncer in front later would silently break the fence, so that pairing needs a guard (a startup check, and a note wherever the connection string is configured). Leader flapping under a slow API server is the usual lease failure mode; the renew interval and expiry need to be tuned against the hub's observed API latency rather than copied from defaults. Pack materialization to scratch adds a failure mode (disk pressure on the node's emptyDir) that the PVC never had; the trees are small and short-lived, but the error path has to say what ran out.

Alternatives Considered

Evidence, runs and packs to object storage (S3) with a ReadWriteMany volume as the no-S3 fallback (rejected)

  • Rejected because: the hub has no provisioned object storage and ReadWriteMany was never confirmed on target clusters; Postgres bytea removes the second storage system instead of adding one.

Payload bytes as chunked bytea in Postgres beside the existing pointer rows (accepted)

Concurrent reconcile (N active writers over FOR UPDATE SKIP LOCKED) (rejected)

  • Rejected because: Much larger change with no demand: workload is bounded by LLM spend, not controller throughput.

ADR-0050: Per-attempt deadlines kill the process group and fail measured

Status: accepted | Date: 2026-09-23

References: RFC-0002:C-PLAYBOOK-BUDGET

Context

RFC-0002:C-PLAYBOOK-BUDGET requires every task to run under a bounded deadline and requires the run's wall-clock ceiling to terminate attempts in flight. The executor only checked the ceiling between dispatches, so one hung agent turn or command held a run for as long as it hung. Three things had to be decided that the clause does not settle: where the operator's default deadline and maximum come from, how a runner ends an attempt, and what a timed-out attempt settles as.

A command task is sh -c <command>, and an agent turn under the command backend is too. Killing the shell alone leaves whatever it started running and holding the output pipes the runner is draining, so the runner stays blocked for as long as the grandchild lives. The local agent harness has the same shape: the CLI starts tool and MCP subprocesses of its own. The openshell turn has no local child at all; it is a gRPC exec whose unwinding already runs through a cancellation token.

Decision

The run's wall-clock ceiling is both the default deadline and the maximum. A task declares timeout to be bounded more tightly; an undeclared task is bounded by the ceiling alone, and a declared timeout longer than the ceiling is refused before dispatch. The executor computes each attempt's deadline as the task's timeout counted from that attempt's start, or the ceiling's instant if that is earlier, and hands it to the runner with the attempt. A batch member carries its own deadline.

A runner that spawns a process under a deadline spawns it as the leader of a new process group and kills the whole group with SIGKILL when the deadline passes. A watchdog thread does this while the caller drains the pipes; it is stopped before the leader is reaped, so a kill can never reach a recycled group id. The openshell turn trips its existing cancellation token at the deadline instead.

A killed attempt returns its own outcome, TimedOut(deadline), which settles as a measured failure whose note names the limit that ended it and is never retried. When that limit was the run's ceiling, the run halts on the ceiling rather than short-circuiting on the task. Without a deadline nothing changes: the child shares the engine's process group as before.

Consequences

A hung task costs at most its own limit, and a run past its ceiling ends at the ceiling rather than whenever its last attempt returns. The note says which limit fired, so an operator can tell a slow task from a run that was given too little time.

Children under a deadline leave the terminal's foreground process group, so a ctrl-c no longer reaches them through the terminal. plan run therefore installs a SIGINT handler that terminates every live group before exiting, and the groups are tracked in a process-wide list, which is the one piece of global state here: a signal handler has no other way to reach them. A group member that calls setsid escapes the kill, as it would escape any process-group based supervision.

SIGKILL gives an agent CLI no chance to flush. A timed-out turn loses whatever it had not written, which is acceptable for an attempt that is being failed anyway.

Operators who want a per-task default tighter than the run's ceiling have no knob for it; a pack states it per task. Adding one later is additive.

Alternatives Considered

The run's ceiling is the default and maximum; kill the process group; settle a timed-out attempt as a measured failure (accepted)

  • Pros: No new operator knob: the one ceiling every playbook launch already requires bounds every task, A group kill ends a shell and everything it started, so the runner is never left draining a pipe a grandchild holds, A measured failure is never retried, so a slow task does not cost its limit three times
  • Cons: Children leave the terminal's process group, so ctrl-c needs an explicit handler and a list of live groups, A task that calls setsid escapes the kill

Separate operator flags for a default per-task deadline and a maximum (rejected)

  • Pros: Lets an operator bound undeclared tasks more tightly than the run
  • Cons: Two more required launch parameters on every surface that launches a playbook: the CLI, the rendered pod, and the controller, A maximum above the run's ceiling is meaningless and one below it is rarely wanted
  • Rejected because: The run's ceiling already has to be set and already bounds every task; a second pair of knobs adds launch surface without adding a bound anyone has asked for.

Settle a timed-out attempt as a transport failure (rejected)

  • Pros: Retries would absorb a task that was slow only once
  • Cons: Each retry spends the whole limit again, tripling the cost of a task that is simply too slow, Transport is for infrastructure that failed; a task that ran out of time is the work's own outcome
  • Rejected because: A timeout is evidence about the task, not the transport. Retrying it repeats the time without learning anything.

Kill only the direct child (rejected)

  • Pros: Children stay in the engine's process group, so ctrl-c reaches them without a handler
  • Cons: sh -c commands and agent CLIs leave grandchildren holding the output pipes, and the runner blocks until they exit
  • Rejected because: It does not end the attempt, which is the whole point.

ADR-0051: Refresh minted GitHub tokens in place for runs that outlive them

Status: accepted | Date: 2026-09-28

References: ADR-0028, ADR-0036, RFC-0002:C-PLAYBOOK-BUDGET

Context

A mint://github-app secret is a GitHub App installation token, and GitHub caps those at one hour. The controller mints one per dispatch, and ADR-0036 delivers it in the run's owner-referenced Secret, projected into the pod as an environment variable or as a file mounted with subPath. Neither projection changes after the pod starts: the environment is fixed at exec, and the kubelet does not update a subPath mount when the Secret changes.

An agent reaches a secret only by relay, and the relay is narrower still. The engine reads the env projections that CRUCIBLE_AGENT_VISIBLE_ENV names out of its own environment once, at startup, and hands those values to every sandbox it later creates. A file projection never reaches a sandbox at all. An agent's token therefore dies an hour after the pod started, however many turns later it is used.

A playbook that waits on an external system runs far past an hour. A playbook that watches a CI run for up to ten hours, then fixes and rebuilds, holds one pod for many hours, so every token it was given is dead long before it pushes a branch or dispatches the rebuild. RFC-0002:C-PLAYBOOK-BUDGET lets a run's wall-clock ceiling be as long as its launcher allows, so nothing stops a launch from asking for a run that its own credentials cannot finish.

The app key stays in the controller's custody under ADR-0028. Whatever refreshes the token either holds the key or asks the controller.

Decision

The hub controller re-mints every minted secret of a live run once the current token has less than 30 minutes left, and writes the new bytes into that run's Secret. It finds live runs by sweeping the work_pods rows that are Running, and it writes through the per-cluster dispatcher that created the Secret, merge-patching only the minted keys so that co-delivered Vault values are not re-read. The sweep runs in every build, not only under the autoresearch feature that gates today's work-pod sweep. The run never asks for a token.

A minted secret projected as a file is mounted by directory, not by subPath, so the kubelet carries each rewrite into the running pod. Its declared path therefore names a file in a directory the projection owns alone, and registration refuses one that does not. A command reads the file when it needs the token, through a git credential helper and a gh wrapper the runtime image ships.

A file projection can be agent-visible, and the engine relays it by reading the file each time it creates a sandbox, not once at startup. The file holds between 30 and 60 minutes of validity, less the kubelet's propagation delay of a minute or two, so an agent turn starts with roughly half an hour or more.

A minted secret projected into the environment keeps working as it does today and is not refreshed. The controller warns at launch when a run projects one into the environment and asks for a wall-clock ceiling longer than the token's lifetime.

Consequences

A run can hold GitHub credentials for as long as its ceiling allows, without the key ever leaving the controller and without a new endpoint the run can call. A pack that reads the file through the helper or the wrapper sees no difference.

The engine's relay and the agent-visibility list both change. Today visibility names environment variables; it must also name a file projection, and the engine must read that file per sandbox create. That touches the controller/engine boundary RFC-0004 inventories.

The hub has per-run work for as long as a run is live, on every spoke it dispatches to. A hub that is down lets tokens lapse once the file's remaining validity runs out, which is 30 minutes in the worst case and 60 in the best. ADR-0049's leader election covers a single replica failing.

A single agent turn longer than its relayed token's remaining validity loses GitHub access partway through, because the sandbox holds a copy and not the file. Agent rounds today run 10 to 20 minutes, so this is the exception. The egress rewrite alternative below removes the limit and is the intended successor.

A pack that consumes the env projection, as examples/fips-watch does, keeps the one-hour limit. The launch warning makes that visible before the run rather than an hour into it.

Alternatives Considered

Re-mint on a timer into the run's Secret, mounted by directory; commands read it per use; the engine relays the file at each sandbox create (accepted)

  • Pros: The app key stays with the controller and the run gets no new way to ask for credentials, Reuses the Secret, projection, owner reference, and per-cluster dispatcher ADR-0036 already built, Invisible to a pack that reads through the helper or wrapper
  • Cons: The hub does per-run work for the run's whole life, on every spoke, The relay and agent-visibility list change across the controller/engine boundary, An agent turn longer than the relayed token's remaining validity loses access, Environment projections are not covered

The OpenShell egress proxy substitutes a freshly minted token for a placeholder, as the fork's AWS provider does. Deferred as the successor to the accepted option.

  • Pros: The token never enters the sandbox, so it cannot leak or go stale there, Covers agent turns of any length
  • Cons: Needs a GitHub provider in the OpenShell fork and a way for the gateway to mint without holding the app key, Covers the sandbox only; commands run in the pod, not behind the proxy

The run asks the broker for a token when it needs one (rejected)

  • Pros: Fresh on demand, no timer
  • Cons: The broker does not hold the app key, so it needs a new authenticated call back to the hub controller, Adds a credential-issuing tool to the surface a run can call
  • Rejected because: It moves credential issuance into the run's reach to solve a lifetime problem the controller can solve without the run asking.

Deliver the app key and let the pack mint its own tokens (rejected)

  • Pros: No controller work after dispatch
  • Cons: Puts the app key in every pod that binds the secret, against the custody rule of ADR-0028
  • Rejected because: Key custody is the point of minting.

ADR-0052: Run history is a controller-supplied view of a launch series

Status: accepted | Date: 2026-09-28

References: RFC-0002:C-RUN-HISTORY, ADR-0027, ADR-0037

Context

A scheduled playbook that repairs something works better when it knows what its last few runs found and tried. The runs that timed out or could not fix anything are the ones the next agent most needs to see.

The one channel between runs today is the schedule cursor. It is a position, not a log. It advances only when a run finishes, so a failed or timed-out run leaves no trace. In its file form, the pack writes the file and the controller stages it into the next firing, which makes the pack carry and prune its own history. ADR-0027 kept dedupe state out of the engine, and RFC-0002:C-TASK-FILES forbids declared files as a channel between runs. The cursor is the one sanctioned exception, and it exists for input positions. The controller's ledger already holds every run of every standing launch (ADR-0037) with its outcome and session.

Decision

History is a read-only view the controller builds from its own ledger, and no run writes to it. What each run contributes is the declared output of one task the pack names as its record. RFC-0002:C-RUN-HISTORY states the record's shape, the ordering, the bounds, and how a task receives it.

The pack declares history, not the schedule. Whether a task reads its last few runs is part of what the pack does, so it belongs where the pack's tests can see it, and a manual launch simply gets nothing.

The series is the standing launch.

The controller does not compile the pack to learn how much history it needs. It sends up to the engine's ceiling with every launch in a series, and the engine trims to each task's depth.

Consequences

A failed, stopped, or timed-out run is part of the history. Retention belongs to the controller, so a pack never prunes anything.

What one run can tell the next is exactly what its record task declares. An earlier agent's text reaches the next agent's prompt only as marked external input.

A pack's record shape can change between revisions within one series. Each record carries the revision that produced it, and a reader has to tolerate older shapes.

Every launch in a series carries up to the ceiling's worth of records, even when no task reads history. A resumed run must see the history the interrupted run saw, so the supplied history is persisted with the run, which costs ledger storage per run.

Two overlapping runs of one series can see different histories, because a run still in flight is not part of the history.

The engine needs the history at start, so the launch contract gains one more supplied input across the controller/engine boundary RFC-0004 inventories.

Alternatives Considered

The pack declares a record task and per-task depth; the controller supplies up to the ceiling from its ledger and the engine trims (accepted)

  • Pros: Includes every terminal run, failures and timeouts too, No state the run writes; retention and bounds stay with the controller, Visible and testable in the pack, The controller never compiles a pack to launch it
  • Cons: One more input across the controller/engine boundary, Records travel with every series launch whether or not a task reads them

The controller compiles the pack and sends exactly the depth its tasks declare (rejected)

  • Pros: Sends only what the run will read
  • Cons: The controller compiles the pack before every launch, which today only the engine does, and needs the compiled plan's depths across the boundary
  • Rejected because: At most 30 bounded records is cheaper than a second compilation path in the controller.

Extend the schedule cursor file to advance on every terminal outcome (rejected)

  • Pros: Reuses the cursor table and staging
  • Cons: A cursor that skips past an unfinished run is wrong for the input-processing packs cursors exist for, The pack writes and prunes its own log, which is a store between runs
  • Rejected because: A cursor is a position and must not advance past unprocessed input; history is a log that must include failures.

The schedule declares the history depth (rejected)

  • Pros: No DSL change
  • Cons: The pack cannot tell whether it will receive history, and its tests cannot exercise it
  • Rejected because: Reading history is the pack's behaviour, so the pack states it.

The pack queries the controller's run API for its predecessors (rejected)

  • Pros: No new engine input
  • Cons: Needs a controller credential inside the run, No projection: the pack could read prompts, stdout, and files of earlier runs
  • Rejected because: It hands the run a credential and bypasses the declared-output projection that keeps what crosses runs bounded.

ADR-0053: Run supervisor services come from an attested catalog, not broker plugins

Status: proposed | Date: 2026-09-28

Context

A task sometimes needs a capability whose credential must never enter the sandbox: submitting cluster work, reading or commenting on JIRA, dispatching CI. The broker is the only way to provide one today. Each new integration is broker code and a broker release, and the broker holds every such credential for every run whether that run uses it or not.

Workloads differ in what they need. An autoresearch run that edits a control-plane resource such as a KServe object may need a whole disposable virtual cluster (tlrmchlsmth/oubliette provides one, with Kueue admission kept on the host). Most agent workflows need none.

The run supervisor is the per-run trusted process that runs the engine; it is the only place a credential can sit that the sandbox cannot reach.

Decision

Describe the decision that was made. What is the change that we're proposing and/or doing?

Consequences

  • New integrations ship as catalog tools in the run supervisor image instead of broker code; the broker keeps its built-in capabilities.
  • A pack cannot run its own code with a service's credentials: the executable is catalog-attested and arguments may not name pack or workspace paths. Pack command tasks already share the run supervisor's reach, and nothing here narrows that.
  • A run that declares no services is unchanged, and services start only when a task that needs one dispatches.
  • Resources a service creates outside the run supervisor, such as cluster jobs, are not counted against the run's cost ceiling; the target's quota bounds them.
  • The catalog is new supply-chain surface: adding a tool to the image is a trust decision reviewed like a broker change.

Alternatives Considered

Manifest-declared services from an image-attested catalog, reached by capability through needs (accepted)

  • Pros: No broker release per integration, Credential held only for runs that need it, Provenance checkable before any spend, Works for a whole virtual cluster and for a narrow tool alike
  • Cons: A catalog format and attestation to maintain, A second mediated path beside the broker

Add each integration to the broker as a built-in capability (rejected)

  • Pros: One mediated path, No new manifest surface
  • Cons: Every integration is a broker release, The broker holds every credential for every run, Cannot carry a per-run disposable target such as a virtual cluster

Declare services in the workflow graph as tasks (rejected)

  • Pros: One place to read a workflow
  • Cons: A service is long-lived environment, not a unit of work that settles, Graph shape would depend on credentials and images

Let the pack ship the service executable (rejected)

  • Pros: No catalog needed
  • Cons: Pack code would run outside the sandbox with credentials the pack never held

ADR-0054: External text converts to a withheld placeholder and markers are refused

Status: accepted | Date: 2026-09-23

References: RFC-0002:C-PLAYBOOK-PARAMS

Context

RFC-0002:C-PLAYBOOK-PARAMS requires a supplied parameter value to reach an agent prompt only inside a marked external-input region. The compiler carries such a value as ExternalText, whose segments remember their provenance through +. But starlark renders any value as a string through Display: str(), repr(), %-formatting, .format(), and the repr of a list or dict holding the value all call collect_repr or collect_str, and those hooks return nothing and cannot fail. ExternalText's Display wrote the raw value, so any of these produced a plain string the prompt renderer could not mark, and a hostile value carried a forged close marker and instructions straight into the prompt.

Separately, the renderer stripped marker text out of a supplied value to a fixpoint. That kept the region intact but silently: a launcher that tried an injection got a working run and no signal.

Decision

ExternalText's Display yields a placeholder, U+FDD0 + "external text" + U+FDD0, and never the value. Every string that crosses into a DSL constructor (arguments, list and dict items, dict keys, and the plain spans joined to external text with +) is refused with ExternalConverted when it contains U+FDD0. The value's text is therefore absent from any conversion, and the conversion is a compile error at the constructor that receives it.

Attribute access on ExternalText yields a method object whose call throws ExternalMethod at the call site, so v.upper() and friends fail with a message that says to join the value with +, instead of starlark's missing-attribute error.

The renderer refuses, with ExternalCarriesMarker, an external span that contains either marker's text, checked after + has joined adjacent external values. It no longer strips.

Consequences

The security property does not depend on the check: a conversion never holds the value, so even a conversion whose placeholder is sliced beyond recognition (str(v)[1:-1]) yields no hostile text. The compile error is best effort over that and catches every conversion a pack author writes by accident, including after case changes and most string methods, because a noncharacter survives them.

A refused conversion is reported at the constructor that received the string, not at the str() or % that made it, since those hooks cannot raise. Method calls are reported at the call.

hasattr(v, name) is true for every name on external text. v == "x" stays identity comparison and is always false for a supplied value; that is a separate footgun, not a leak.

A pack literal containing U+FDD0 is refused. Noncharacters do not occur in authored text.

A launch whose value carries a marker now fails to compile with the marker named, so the operator sees the attempt; before, it ran with the marker removed.

Alternatives Considered

Display yields a withheld placeholder; constructors refuse strings carrying it; markers are refused at render (accepted)

Shadow the str and repr builtins with versions that refuse external text (rejected)

  • Rejected because: Covers two of the paths. %-formatting and .format() are string internals that call collect_repr directly, and shadowing str breaks it as a type in annotations.

Keep Display writing the value and rely on a placeholder-free check downstream (rejected)

  • Rejected because: There is nothing downstream to check: once Display has written the value, the result is an ordinary starlark string indistinguishable from one the author typed.

Keep stripping marker text from a supplied value and log a warning (rejected)

  • Rejected because: The compiler has no warnings channel to the launcher, and a rewritten value still runs. Refusing names the marker to whoever launched the run and costs nothing legitimate, since no real value contains the marker text.

ADR-0055: Webhook triggers: verified deliveries land in an inbox and CEL maps them to a launch

Status: accepted | Date: 2026-09-29

References: ADR-0033, ADR-0037, ADR-0043, ADR-0049, ADR-0054, RFC-0002:C-PLAYBOOK-PARAMS, RFC-0003:C-DECISION, RFC-0003:C-ENFORCEMENT, RFC-0003:C-WEBHOOK-DELIVERY, RFC-0003:C-WEBHOOK-SETTLEMENT, RFC-0003:C-WEBHOOK-LIFECYCLE

Context

Standing launches fire on a time (ADR-0033) or a tracker query hit (ADR-0037). Neither covers an event another system pushes: a new image lands in a quay.io repository, a GitHub release is published, a GitLab pipeline finishes. Polling each source is possible for some (the image catalog already reads OCI registries) but it is per-source code, adds latency, and does not exist for most senders. Every one of these senders can POST JSON to a URL.

ADR-0043 assumed no public ingress. That no longer holds: the prod namespace is an ext namespace and can publish a Route on the public router shard. This ADR does not amend ADR-0043; its pollers stay as they are, and the one public route here is the delivery route.

Senders disagree on authentication. GitHub signs the body with HMAC-SHA256 in X-Hub-Signature-256; the Standard Webhooks spec signs id.timestamp.body in webhook-signature with a timestamp to bound replay; GitLab sends a shared token in X-Gitlab-Token; quay.io signs nothing, and receivers such as Kargo and Flux authenticate it by an unguessable token in the URL path.

Senders also disagree on payload shape, and a playbook's params are typed and validated against its schema. Something owned by the trigger has to decide whether a delivery is relevant and turn it into params, and it runs inside the controller on input from the public internet.

Constraints carried over from ADR-0037: one fire path, the trigger never touches the authorization columns, and immunity to the self-retrigger loop (a playbook that pushes the image it listens to).

Decision

Add a webhook trigger as a fourth ADR-0037 sidecar, one per standing launch. playbook_webhooks holds the verifier kind, the secret, the transform, the launch rate ceiling, and the delivery retention. The contract it implements is RFC-0003:C-WEBHOOK-DELIVERY, RFC-0003:C-WEBHOOK-SETTLEMENT, and RFC-0003:C-WEBHOOK-LIFECYCLE; this records how. Launches record origin webhook; the consumed-key row pairs each launch with the delivery that produced it and outlives delivery retention.

Receiving. The controller serves POST /hooks/{id} (and /hooks/{id}/{token} for the path-token verifier) on a second listener with a concurrency limit. Only that listener is published on the ext Route; the API listener stays behind the auth proxy. The handler applies the body cap, looks the webhook up, and verifies. Every comparison is constant-time and every unrecorded delivery gets the same 404. Response timing is not equalized: an id carries 74 random bits, so an existence probe has nothing to aim at. The concurrency limit sheds load before the lookup, the same way for every id. A verified delivery is written to a webhook_deliveries row with its headers, minus the one that carries the credential, and the handler returns 202. It never launches. The path token is redacted from controller logs and traces.

Verifiers. hmac_sha256 (GitHub's X-Hub-Signature-256) and path_token (quay.io), the two with a sender to serve. A verifier and its header are fixed at creation; changing either is a delete and a new webhook, so no header ever carried a secret the current verifier does not know about. The controller generates every secret, so a sender is configured by pasting what the create response returns once. HMAC secrets are sealed with AES-256-GCM under the controller's credential key and bound to the webhook id, because verifying needs the plaintext; tokens are stored as SHA-256 digests, like API keys.

Firing. The webhook's LaunchTrigger reads unsettled delivery rows as its due set, oldest first. The transform runs at claim time, not in the handler: a transform edited after a delivery arrived applies to it, and the sender never sees a transform result. The claim locks the webhook's sidecar and core rows and evaluates under that lock, so a concurrent disable waits for it and a delivery that settled while the launch was being minted rolls the launch back; the save-time rules keep the hold short. Two changes to the ADR-0037 core follow, and schedules and watches move onto both:

  • The per-firing overlay becomes a map of param name to JSON value, converted to the stored param form before standing::fire validates it. Today it is one (param, string) pair.
  • LaunchTrigger::claim gains a third result, settled without launching. It commits the sidecar's settlement and skips fire. Today a false claim rolls back and an Err aborts the sweep, so a delivery could neither settle filtered nor survive a transform error.

A transform error, an underivable dedupe key, an unparseable body, or a schema refusal of a derived value settles failed and does not count toward auto-disable, because a sender chooses all of them. Only failures of the firing itself count: the pack is gone, the stored params drifted from the schema, the decision denied, or an insert failed. Such a firing rolls back its claim, and the webhook's fail settles the delivery failed with the firing's reason in its own transaction, so a failed delivery never blocks the ones behind it. Auto-disable disables the webhook with its standing launch and is audited with the controller as actor; a delivery to a webhook whose standing launch is disabled is treated as a delivery to a disabled webhook. Disabling or deleting a webhook, by a caller or by auto-disable, settles its pending deliveries failed.

Transform, in CEL. A webhook stores a filter expression (bool), a dedupe expression (string), and a derive map of param name to expression. There is no title expression: the launch title is the playbook's, so nothing the sender wrote reaches a run except as a param. The environment is body (the parsed JSON), headers (names lowercased), delivery (the delivery id, unique per delivery), and received_at. Expressions compile at save time with a parser recursion limit, and the save is refused if any does not compile, reads a variable other than the four, calls a function the interpreter does not define (the parser accepts any name, so join() would otherwise fail on every delivery), or derive names a param the target does not declare. The interpreter has no evaluation budget, so the save also walks the AST and refuses an expression that is over a length cap, nests comprehensions more than two deep, or has a comprehension whose loop condition or step references anything but its own iteration and accumulator variables. That last rule is what bounds cost: without it body.l.map(x, body.l) is quadratic in the body and took 113 s on an 11 KB body under 0.14.5. It also refuses the legitimate items.exists(i, i.tags.exists(t, t == i.name)), which is the price. The nesting cap stops [a, a].exists(b, [b, b].exists(...)) from doubling at every level.

body is built by our own JSON conversion, not the crate's serde path, which maps a non-negative integer to uint so that body.count + 1 fails as uint + int. The conversion maps an integral number that fits to int and anything else to double. The ANTLR parser recurses once per bracket level before its own depth limit applies, and a debug build overflowed a 2 MB stack at 20 nested parentheses; a stack overflow aborts the process and no catch_unwind catches it. So a save refuses bracket nesting past 32 before parsing, and parsing and evaluation run on a scoped thread with a 16 MB stack, where a panic is a join error. A parse error is reported as its first diagnostic only, since the crate repeats the source once per recovery attempt. A save may carry a sample payload; the endpoint returns the filter result, the overlay, and the dedupe key without storing anything. A check endpoint answers every refusal a save would, with the parser's line and column when it gave one, so an editor marks expressions as they are typed.

Dedupe. (webhook, dedupe key) is a seen-set written in the launch's transaction, the guard playbook_watch_hits already uses. Only a launch consumes a key, and the seen-set outlives delivery retention. A GitHub push keys on the pushed commit and a release on its id. quay.io's repo_push payload names the pushed tags and no digest, so its preset keys on delivery and every push launches; for quay the loop guard against a playbook that pushes the image it listens to is the rate bound, until the controller resolves the pushed digest from the registry itself. Sender retries settle duplicate. Neither verifier bounds replay by time, so replay protection is exactly the dedupe key.

Rate. A webhook carries a maximum number of launches per hour, set by the owner and capped by a platform maximum. The count is taken under the standing launch's row lock so two replicas cannot both admit the last slot. A delivery past it settles throttled and is never retried.

Presets (quay.io repository push, GitHub release, GitHub push) prefill the verifier, filter, dedupe key, and params map in the form. They are data, not code paths.

Consequences

  • One public route family on its own listener. Nothing else in the API is reachable from the internet, and a webhook firing is decided as its standing launch's firing under RFC-0003:C-DECISION, with the owner's memberships resolved at firing time.
  • The delivery route sits beside the ingest drop-box outside RFC-0003:C-ENFORCEMENT; webhook management and delivery reads stay bound routes.
  • The ADR-0037 claim contract grows a third result and the overlay becomes a map. That touches schedules and watches; their existing tests are the regression net.
  • Every verified delivery has a recorded outcome, which answers "why didn't it fire" from the SPA instead of from logs. A sender never sees a filter or schema failure; the delivery log is the only place it shows.
  • A holder of a leaked secret can launch the playbook as its owner, with any param values the filter admits, up to the ceiling, until the secret is rotated. The param schema is the only bound on those values, so a pack meant for webhooks should constrain its params tightly.
  • A throttled delivery is lost. A burst, or a holder of a leaked token, can exhaust the ceiling and block legitimate deliveries until the token is rotated. Re-running a lost event is an ordinary launch from the form.
  • A token holder can also claim a predictable dedupe key (a release id, a commit that is already public) before the real event, by getting a launch through the filter first. HMAC senders are safe from this because the key comes from a signed body; a path-token sender is not.
  • Rotating a secret is an update. This design has no overlap window: deliveries signed with the old secret 404 until the sender is updated.
  • The path token appears in router access logs, which the controller does not control.
  • CEL is a new dependency: the cel crate from cel-rust (MIT; antlr4rust BSD-3-Clause, everything else MIT or Apache-2.0). Release 0.14.5 builds map and filter results by copying the accumulator every step, so a harmless map over a 50k-element list took 34 s. Upstream fixed this in #341 after that release; on master the same call takes 6 ms. Until a release carries it, the controller pins cel-rust by git rev under deny.toml's allow-git, as it already does for tower-sessions-stores. Master also changes Context to two lifetimes.
  • Evaluation is synchronous on the sweep and cannot be preempted by a timeout; the bound is the save-time rules plus the body cap. The sweep serves every standing launch, so a slow expression would delay schedules and watches too, not just its own webhook.
  • With those rules, parsing the quay filter takes about 13 µs and evaluating it about 15 µs. Nothing in a panic probe set (overflow, division by zero, bad regex, bad timestamp, out-of-range index, deep nesting) panicked; the one reachable panic! in the interpreter is on an unspecified AST node, which the save-time walk refuses.
  • One URL per webhook. Three playbooks on one quay repository means three notifications on the quay side. Shared receivers with fan-out are a follow-up that would add a subscribe action under RFC-0003.
  • The image catalog's registry poll stays the no-ingress path for images. It could become a standing trigger later; it is not part of this decision.

Alternatives Considered

Webhook sidecar on the standing-launch core; the handler verifies and stores to an inbox, the sweep transforms with CEL and fires (accepted)

  • Pros: Reuses the one fire path, the firing decision, and the HA claim., A delivery survives a controller restart between receipt and launch., Every delivery has a recorded outcome.
  • Cons: A new public listener and Route., A new dependency., Changes the ADR-0037 claim contract and overlay shape., A delivery launches once the leader hears of it over Postgres LISTEN; one missed during a reconnect waits for the next discovery tick.

Launch synchronously in the request handler (rejected)

  • Pros: Immediate launch., No inbox table.
  • Cons: A second launch-minting path beside the sweep's., A sender that times out and retries races the first request's launch., A burst of deliveries is a burst of launches before any ceiling is read.
  • Rejected because: Duplicates the fire path ADR-0037 made single, and gives sender retries a race against dedupe.

A webhook gateway in front (Argo Events, Tekton Triggers, Knative Eventing) that calls the launch API (rejected)

  • Pros: Mature sender adapters and filters.
  • Cons: Needs an operator and CRDs in a namespace-scoped deployment., The gateway would hold an API credential that launches as one principal, so per-trigger ownership is lost., Trigger config lives in Kubernetes objects instead of owned controller resources.
  • Rejected because: Launches would carry the gateway's identity rather than the trigger owner's, and ADR-0043 rules out running as an operator (no CRDs, no reconciling Kubernetes objects it did not create).

Poll the source instead (a registry watch trigger for images) (rejected)

  • Pros: No public ingress., The image catalog already reads OCI registries.
  • Cons: Per-source code for every sender., Latency of the poll interval., Most senders have nothing to poll.
  • Rejected because: Ingress is available, and polling does not generalize past images. It remains the path for a deployment without ingress.

Transform as JSON Pointer extraction with equality filters and string templates (rejected)

  • Pros: No new dependency., Trivial to evaluate safely.
  • Cons: Cannot express membership or list logic, such as a tag being among the pushed tags., Every missing case becomes a new filter operator.
  • Rejected because: The quay case already needs "latest" in body.updated_tags, and the operator set would grow into an ad hoc language.

Transform in Starlark, the language packs are written in (rejected)

  • Pros: Already a dependency., Familiar to pack authors.
  • Cons: Owner-authored general-purpose code running in the controller process on public input., Needs step and memory limits the controller does not have today.
  • Rejected because: More power than a filter and a field map need, and the wrong trust boundary for code evaluated against internet input.

Transform in jq (rejected)

  • Pros: Widely known for JSON reshaping.
  • Cons: Expressions yield streams, so a filter or a single param value needs conventions on top., Untyped: a bool filter and a string key are both just output.
  • Rejected because: CEL gives typed single-value expressions and is what Tekton Triggers and Kubernetes admission use for this job.

Shared receivers: one URL and secret that several webhooks subscribe to (rejected)

  • Pros: One sender-side notification can drive several playbooks.
  • Cons: A receiver becomes a root resource of its own, and subscribing across owners needs a new action and policy.
  • Rejected because: Deferred rather than refused. Senders already allow several notifications per repository, and 1:1 fits the ADR-0037 sidecar model without new authorization.

ADR-0056: Launch triggers wake the launch loop at their due time, off the discovery tick

Status: accepted | Date: 2026-09-30

References: ADR-0037, ADR-0049, ADR-0055

Context

Every ADR-0037 trigger fires from one TriggerSweep, and the daemon runs that sweep as a discovery source on the discovery timer (300 s default, and in prod). A one-shot, including one created to fire now, and a cron window both wait for the next tick. On average that is 150 s after the due time; at worst it is 300 s. ADR-0055 added a Postgres LISTEN wake for webhook deliveries, and a second channel wakes the leader's pending-launch source for launches another replica accepted. PgListener::recv reconnects silently, though, so anything announced during a reconnect also waits for the tick.

The daemon's select! runs each arm inline. A discovery poll runs the trigger sweep and then the autoresearch GitHub polls, and in prod it holds the loop for 24 to 30 s of every 300 s. While it runs, a webhook wake and a pod-completion edge both wait.

Launches minted by the sweep go into the queue with plain enqueue, so they sit behind the startup re-enqueue. Prod restarted three times in one hour, and each restart replays about 1,400 non-terminal keys through the single worker, which takes about 30 s. Launches from the UI already use enqueue_urgent, which pushes to the front of the queue, so a burst of urgent keys runs newest first.

Dispatch itself is not the bottleneck. dispatch_run takes 0.76 s at the median and 5.6 s at p95, and one worker drains 1,400 keys in 30 s.

Constraints:

  • Only the ADR-0049 leader fires and dispatches. Any replica can write a one-shot, a schedule, a delivery, or a launch, and a launch the standby accepts lands in a queue no worker drains.
  • The durable state is already in Postgres, and every trigger's due column has a partial index: playbook_one_shots (fire_at) WHERE status = 'pending', playbook_schedules (next_due_at) WHERE enabled, and pending deliveries. Undispatched launches (issues at new with input kind playbook) have none. The claim is a CAS, so an extra pass cannot double-fire.
  • Each trigger claims at most 32 rows per pass.
  • Watches poll an external tracker, so they have no due time to wait for.

Decision

The one-shot, schedule, and webhook triggers move out of the discovery source into a launch loop, which runs inside the leader's run scope and is cancelled on step-down like the other daemon tasks. The watch trigger and the autoresearch polls stay on the discovery timer. The pod-completion stream feeds the queue from its own task in the same scope, so it no longer waits behind a discovery poll.

Probe instead of notify. The launch loop asks Postgres for the earliest due time across four sources: the three trigger tables, limited to enabled standing launches, and undispatched launches, due from their updated_at. A partial index on the last makes all four index lookups. It runs a pass when that time has arrived. Otherwise it sleeps until that time or for a short probe interval (2 s), whichever is sooner. A row written on any replica is seen within one probe interval, and a known due time is met on the dot. No SQL trigger or LISTEN connection is involved, so a reconnect cannot lose a wake. This replaces the ADR-0055 delivery channel and the launch channel, whose notifies and listener are removed.

Rows that stay due. After a pass, a row it deferred (owner refresh) or skipped (an earlier failure on the same standing launch) is still due. The loop keeps those ids in memory with a retry time one discovery interval out, and excludes them from the probe until then. A trigger that hit its 32-row cap gets another pass straight away. An undispatched launch the pass enqueued is held the same way, so one that stays new (waiting on the concurrent-pod cap, or deferred on an unreachable issuer) is re-enqueued once per discovery interval rather than on every probe. A pod completion wakes the launch loop once its reconcile has run, so a freed slot re-drives the capped launches at once, oldest first.

Webhook housekeeping. Settling a disabled webhook's pending deliveries and pruning deliveries past retention ran inside the webhook sweep. A pass now runs only when something is due, so both move to a discovery source of their own.

Queue order. The launch loop enqueues what it fires with enqueue_urgent. Urgent keys keep FIFO order among themselves, so a burst of firings runs in due order ahead of the backlog.

The manual reconcile also wakes the launch loop. A wake, from either source, releases every held row. The daemon's select! keeps shutdown, leadership, verify, manual reconcile, and the discovery tick.

Consequences

  • A one-shot for now fires within the probe interval, and a cron window fires at its time. Webhook deliveries no longer wait behind a GitHub poll or a reconnect.
  • The probe costs four index lookups every 2 s on the leader. It is independent of the number of rows.
  • A launch the standby accepts dispatches within one probe interval, with no connection whose loss could strand it.
  • For launches, the discovery interval now serves only as the retry delay for deferred rows and the webhook housekeeping cadence. Lowering it no longer speeds launches up.
  • Removing the delivery notify drops a Postgres connection the leader held open and the reconnect handling that went with it. Delivery latency goes from roughly immediate to at most the probe interval.
  • A launch minted during a restart skips the replay backlog. Machine-discovered keys stay FIFO behind it.
  • Draft-head schedules are disabled under the adopted-only policy when a launch-loop pass runs, not on every discovery tick. A policy change through the overrides API still disables them at once; a change from elsewhere shows in the stored state at the next pass, and firing is gated either way.
  • Two sweeps can run at once: the launch loop's and the discovery tick's (watches). They cover disjoint triggers, and the per-row claim already guards against the concurrent-sweep case that ADR-0049 handles across replicas.
  • Still one queue worker. The cap check in dispatch_run (count_running, then a later claim) is only correct under one worker, so more workers would first need an atomic admission. The prod numbers do not justify that yet.

Alternatives Considered

A launch loop on its own task that probes the indexed due columns every 2 s and sleeps to the earliest due time (accepted)

  • Pros: Firing latency drops from a tick to at most 2 s, and to zero for a known due time., Sees every writer on every replica with no per-writer code., No LISTEN connection, so no reconnect gap., Keeps the one fire path and the CAS claim from ADR-0037.
  • Cons: A constant, small query load on the leader., A second task that sweeps triggers beside the discovery tick.

Postgres triggers on the due-time tables fire one notify channel that wakes the launch loop (rejected)

  • Pros: Latency close to zero for new rows., Covers every writer, including future ones.
  • Cons: The first SQL triggers in the schema, holding logic the Rust reader does not see., UPDATE OF enabled fires on every write that sets the column, so failures and the leader's own claims cause extra passes unless each trigger has a WHEN clause., PgListener::recv reconnects without telling the caller. Closing that gap needs try_recv, and the wake still depends on one long-lived connection.
  • Rejected because: It adds SQL triggers and a connection whose failure is silent, to beat a 2 s probe on a launch whose pod takes tens of seconds to start.

Call pg_notify from each writer in Rust (rejected)

  • Pros: All logic stays in Rust.
  • Cons: Seven production next_due_at writers plus the one-shot, delivery and enable writers, each needing its own call., A writer that forgets the call fails silently, and it has the same listener reconnect gap.
  • Rejected because: A missed call only shows up as slow firing in prod, and the probe covers the same writers with no per-writer code.

Lower the discovery interval to a few seconds (rejected)

  • Pros: One config change.
  • Cons: The discovery poll takes 24 to 30 s, so it cannot finish within a short interval., Multiplies GitHub and Jira calls by the same factor.
  • Rejected because: The tick carries the autoresearch polls, and their cadence has nothing to do with firing latency.

A durable job queue (SKIP LOCKED table or a queue crate) that triggers insert jobs into (rejected)

  • Pros: A familiar model with per-job retry and visibility.
  • Cons: Duplicates state the trigger tables already hold (due time, claim, settlement)., A second claim protocol beside the ADR-0037 CAS.
  • Rejected because: The trigger tables already form an indexed, durable due-time queue. What is missing is a loop that watches it closely.

Several queue workers so launches stop waiting behind other reconciles (rejected)

  • Pros: Launch latency no longer depends on the other keys in the queue.
  • Cons: The concurrent-pod cap and the daily ceiling are read-then-claim and would need atomic admission first., Reconcile arms that are not per-issue would need an audit for concurrency.
  • Rejected because: Prod shows one worker draining 1,400 keys in 30 s, and urgent enqueue removes the only case where a launch waits. Revisit if the reconcile histogram shows contention.

ADR-0057: Packs are stored by digest, shipped gzipped, and may extend a base pack

Status: superseded | Date: 2026-10-02 Superseded by: ADR-0059

References: ADR-0009, ADR-0033, ADR-0048, RFC-0002:C-PLAYBOOK-COMPOSITE, RFC-0003:C-APPROVALS, RFC-0003:C-READ-SCOPING, RFC-0004:C-CONTRACT-VERSION, RFC-0004:C-LINKED-RENDER

Context

A playbook pack is stored as one gzipped tarball in a Postgres bytea column and delivered to the run pod as one ConfigMap. Three limits apply, and they disagree:

  • A draft holds at most 128 files of at most 512 KiB each (crucible-controller/src/playbooks/drafts.rs:25, :28, enforced at :403-421). A draft file path must be non-blank (drafts.rs:410-412) and pass validate_path (drafts.rs:413), the repository-subtree validator, which on its own accepts an empty path (registry.rs:264-267).
  • A stored tarball is at most 8 MiB gzipped (MAX_PACK_TAR_BYTES, crucible-controller/src/playbooks/registry.rs:32), checked on git registration (registry.rs:472), draft publication (registry.rs:680), and draft save (drafts.rs:663).
  • Delivery renders every pack file, except state/, .git/ and workspace/ (crucible/src/deploy/render/kube.rs:479-511), into one ConfigMap capped at 900 KiB of raw file bytes (CONFIGMAP_MAX_BYTES, kube.rs:199, guard at :396-402). The pack-stage init container copies the ConfigMap projection into an emptyDir at the domain path (kube.rs:769-795, volumes at :1288-1315), because the loop writes STEER.md, state/ and the workspace into that directory. The projection sets mode 0755 on every file (kube.rs:1305), which is where gate scripts get their execute bit.

The controller never checks the 900 KiB budget, so the effective cap is the ConfigMap, found at dispatch: a pack between roughly 900 KiB raw and 8 MiB gzipped registers, launches, and fails with PackOverConfigMapBudget (kube.rs:43-50). The comment at kube.rs:197 says packs are capped well under the budget upstream and cites #134. The claim is wrong, since the 8 MiB gzip cap does not do that, and the reference is stale: #134 is an unrelated workflow-language change.

The tarball is copied whole, not referenced. Registration stores it in playbooks.tar_gz (crucible-controller/migrations/0006_playbooks.sql:20), each draft version in playbook_draft_versions.tar_gz (0012_playbook_drafts.sql:28), each import in pack_imports.tar_gz (0013_pack_imports.sql:15), and each adopted standing launch in adopted_tar_gz (0033_standing_launches.sql:17). Every launch copies it again into pack_tarballs under the launch's slug (0004_shared_state.sql:46-52; registry.rs:1003-1022, drafts.rs:1172-1190, crucible-controller/src/launches/standing.rs:400-420), so a run executes the bytes it was authorized against after the registry moves. The tar is not deterministic: tar_pack_tree writes each entry with the default header mode, which records mtime and ownership (crucible-controller/src/playbooks/packs.rs:31-36, :63), so two saves of the same files produce different digests.

Pinning is already by content. content_digest is SHA-256 over the gzip bytes (crucible-contract/src/artifact.rs:19); a published draft's rev is that digest (registry.rs:715); an import registers only at the commit it was fetched at (crucible-controller/src/playbooks/imports.rs:363); a changed exposure needs its new digest accepted (registry.rs:82-84). Approvals of imports, scopes, and graduations go through RFC-0003:C-APPROVALS.

Every reader of a pack works on a materialized tree in the controller. Draft save compiles the full tree through the linked engine (drafts.rs:652-669, registry.rs:1-7). Dispatch unpacks the launch's pack_tarballs row (packs.rs:101-135, called from crucible-controller/src/runs/launch.rs:352 and issues/reconcile/lifecycle.rs:103) and the render walks that directory (crucible-controller/src/runs/workpod/run.rs:217-229). Scope approval extracts the exposure from the stored bytes (issues/reconcile/scope.rs:406, :618).

The case that breaks this is a pack with a large shared core and per-variant overlays: one workflow, its tools and its prompts reused across several variants, each variant adding a small directory. Measured on one such pack (gzip level 6, the flate2 default):

TreeFilesRawGzipped
Shared core87782 KiB212 KiB
One overlay, typical531 KiB10 KiB
One overlay, largest1576 KiB25 KiB
Core + one overlay, typical92813 KiB222 KiB
Core + one overlay, largest102858 KiB237 KiB
Core + all seven overlays1571051 KiB297 KiB

A single variant fits the ConfigMap with 42 to 87 KiB of raw headroom; the core alone does not exceed it. One tree holding every variant exceeds both the 128-file draft cap and the 900 KiB raw budget, while its gzipped size is a third of that budget. So the author ships one draft per variant, each a full copy of the core: per draft version, per launch, and per run ConfigMap. Every pack under examples/ is under 34 KiB raw, so nothing else is near either limit.

Composite domains (ADR-0009, RFC-0002:C-PLAYBOOK-COMPOSITE) are the existing reuse mechanism, and they solve a different problem. A [composite] manifest names sibling domain directories (crucible/src/manifest/mod.rs:1194-1215, :1267-1278) and checks each out as its own workspace in one run. They combine repositories, not files, and a registered pack is one subtree that cannot reach a sibling.

Kubernetes validates a ConfigMap's size as the sum of data value lengths and decoded binaryData lengths against 1 MiB (ValidateConfigMap, https://github.com/kubernetes/kubernetes/blob/master/pkg/apis/core/validation/validation.go), so a gzipped tarball in one binaryData key counts at its gzipped size.

Decision

Three changes. They land together because a base pack is a reference by digest, which needs storage addressed by digest, and because the composed tree has to stay deliverable.

Delivery: one gzipped key. The render tars and gzips the materialized pack directory (same exclusions as today) into one binaryData key of the pack ConfigMap, and pack-stage extracts it into the domain emptyDir with tar -xzf (the loop image ships tar and gzip, Containerfile.runtime:55). The tar is deterministic (sorted entries, fixed mtime, zero owner, mode 0755 on every regular file to match today's projection), so the in-process and command-line renders stay byte-identical as RFC-0004:C-LINKED-RENDER requires. The 900 KiB budget then applies to gzipped bytes. The stored cap drops to the same budget, checked on the composed tree at save, registration, and publication, so a pack that registers can be delivered. Steering stays in the materialized tree as STEER.md, as materialize_pack writes it today.

Storage: blobs by digest. Pack tarballs move to one table keyed by content_digest. playbooks, playbook_draft_versions, pack_imports, adopted standing launches, and pack_tarballs reference a digest instead of holding bytes; a launch inserts a reference, not a copy. tar_pack_tree writes the deterministic tar above, so an unchanged tree keeps its digest across saves. A blob is deleted when no row references it. Every digest column keeps one meaning: SHA-256 of gzip bytes.

Base packs. A draft may name a base: a registered pack, pinned at its stored digest. The draft holds only its overlay, and the 128-file and 512 KiB caps apply to the overlay. The composed tree is the base tree with the overlay laid over it. An overlay path that exists in the base replaces it, and the editor lists every replaced path. The overlay may replace crucible.toml or inherit it. A base has no base of its own. The base is recorded on the draft version, and on the pack published from it, in a second digest column, base_digest, beside the overlay's tar_digest. A pack's identity is the pair. A launch and a standing launch pin the pair, and a draft-head schedule (ADR-0033) records the head's pair on each launch it creates. One function composes the pair into a tree, and draft save, registration, publication, exposure extraction, and dispatch materialization all call it, so every reader sees the same composed tree and delivery stays one key. Moving to a new base is a new draft version with a new pair; nothing follows a base automatically. A base is a registered pack, so it compiles on its own.

The base is controller metadata, not a manifest key. The engine only ever sees a composed tree, and the manifest is unchanged. Naming a base is a read of that pack under RFC-0003:C-READ-SCOPING.

Compatibility. No pack-level version key. RFC-0004:C-CONTRACT-VERSION admits only an engine whose contract version equals the controller's (versions_match, crucible-controller/src/runs/contract.rs:92-93), and registration compiles through that same linked engine, so a pack that registers is one every admitted engine can run. CONTRACT_VERSION (crucible-contract/src/lib.rs:30-36) versions the controller/engine wire types, not pack content, and stays that way. Packs stored before the change keep their bytes and digests; they move into the blob table under their existing digest.

Specification. Before implementation this needs normative text: a new RFC-0002 clause for base composition (replacement by path, the pair as identity, refusal of a save or launch whose base digest is not stored or not readable), and an RFC-0003:C-APPROVALS amendment stating that an approval of a pack built on a base binds the pair.

Not now: OCI distribution. Publishing packs as OCI artifacts is deferred. It becomes worth its cost when a measured pack's gzipped tree passes half the delivery budget, or when spokes (ADR-0048) should pull pack bytes instead of receiving them in a hub-rendered ConfigMap. The digest-addressed table and the pair identity carry over to it.

Consequences

  • A pack with a shared core and per-variant overlays stores the core once per base version. A variant draft holds its overlay: 5 to 15 files instead of 92 to 102.
  • A launch stores a reference instead of a copy, so pack_tarballs grows by rows, not by pack size.
  • The pack ConfigMap shrinks to the gzipped size: about 222 KiB instead of 813 KiB for a typical variant, written to etcd once per run. A tree holding every variant (297 KiB gzipped) also fits.
  • Registration and draft save refuse a pack over the delivery budget, so PackOverConfigMapBudget stops being reachable from a registered pack. The kube.rs:197 comment is corrected with the change.
  • The rendered pack ConfigMap changes shape from one key per file to one key, so the rendered fixtures are regenerated once. A run pod rendered before the change is unaffected; it reads its own ConfigMap.
  • kubectl get configmap no longer shows pack files as text. Reading a run's pack goes through the controller's pack view, as it does for drafts and registered packs.
  • Draft save, registration, publication, and dispatch read the base blob from Postgres and unpack it with the overlay: one more blob read and about 212 KiB of decompression per call for the measured core. No external dependency is added, so nothing new can block registration, scoping, or pod start.
  • A draft-head launch of a draft on a base pins the pair and materializes the same composed tree as a published launch; nothing is merged into a second ConfigMap.
  • Every comparison of pack identity compares the pair. A comparison that reads only tar_digest misses a base change. The exposure digest check (registry.rs:82-84) is unaffected because it digests the exposure extracted from the composed tree.
  • A base that is re-pinned or retired keeps its blob while any draft version, pack, or launch references its digest.
  • Overlay replacement can shadow a base gate script or tool. The editor and the approval request list replaced paths so a reviewer sees them.
  • Deterministic tar headers change the digest of a tree tarred before and after the change. A published draft republished with no edits gets a new rev once.

Alternatives Considered

Deliver one gzipped binaryData key, store tarballs once by digest, and let a draft extend a registered base pack (accepted)

  • Pros: Removes the per-variant copy of the core from drafts, launches, and run ConfigMaps., The delivery budget measures gzipped bytes, so stored and delivered sizes use one unit., No new store, credential, or delivery path., The engine and the manifest are unchanged; composition is controller-side.
  • Cons: Each run still writes its pack into etcd., Every pack read pays a base unpack., Replacement by path can shadow base files, which the reviewer has to see.

Publish packs as OCI artifacts, a layer per part, pinned by manifest digest, delivered by image volume or an init-container pull

  • Pros: Content-addressed layers deduplicate a shared core across variants in the registry and on the node., Run pods pull directly, so pack bytes leave etcd and the hub-rendered ConfigMap., Same mechanism as Helm charts, Flux OCIRepository, and Tekton Bundles.
  • Cons: Adds a registry to registration, draft save, and dispatch: the controller compiles and materializes the full tree, so it pulls the base on every save and every dispatch, or keeps a cache., pack-stage mounts only the pack ConfigMap and the workdir (kube.rs:769-795). The quay-auth mount goes only on the main container and only when pull_authfile is set (kube.rs:1110-1111), and imagePullSecrets names the engine image's secret (kube.rs:727-729), which may not read a pack repository., How CRI-O lays out artifact layers in an image volume mount is unverified, and containerd does not mount artifacts yet (https://github.com/containerd/containerd/issues/11381), so two delivery paths would exist., Pack contents (prompts, tools, gate scripts) would sit in an external registry, which then has to be private and access-reviewed., At the measured sizes the gzipped tree is a third of the ConfigMap budget, so none of this is needed yet.

Ship one pack holding every variant, selected by a launch parameter (rejected)

  • Pros: No composition; works for a git-registered pack once delivery is gzipped (297 KiB).
  • Cons: 157 files is over the 128-file draft cap, and every save carries the whole tree., One exposure and one approval cover every variant, so a change to one overlay re-approves all of them., Every run carries every overlay.
  • Rejected because: It stays available to git-registered packs, but it does not fit a draft and ties every variant to one approval.

Raise the draft file cap (rejected)

  • Pros: One constant.
  • Cons: A draft is saved whole, so each save and each version still carries the core., Does nothing about the per-launch and per-ConfigMap copies.
  • Rejected because: Lets a draft hold every variant without removing any copy of the core.

Reuse the core through composite domains (rejected)

  • Pros: Existing mechanism (ADR-0009).
  • Cons: A composite combines sibling domains as separate workspaces in one run; it does not merge files into one pack tree., A registered pack is one subtree and cannot reach a sibling directory.
  • Rejected because: Composites combine repositories for one run, not files across packs.

Fetch the pack from a git ref inside the run pod (rejected)

  • Pros: No new store; registered packs already come from git., Imports already pin by commit.
  • Cons: Drafts have no git ref., Every run pod needs forge credentials and forge reachability., A commit pins a repository, not a pack subtree.
  • Rejected because: Drafts and draft-head launches (ADR-0033) have no ref to fetch, and it puts forge credentials and forge availability on every run start.

Store pack tarballs in S3 and have the pod download by key (rejected)

  • Pros: A plain blob store with no size pressure.
  • Cons: Run pods carry no S3 credentials; each cluster needs a new one., Adds a dependency to pod start for packs that fit a ConfigMap once gzipped.
  • Rejected because: Adds a credential to every cluster to solve a size problem the measured packs do not have once gzipped.

Split a large pack across several ConfigMaps in a projected volume (rejected)

  • Pros: No new dependency; extends the current path.
  • Cons: Object count per run grows with pack size and counts against namespace quota., Does nothing about the copies in Postgres.
  • Rejected because: Gzip into one key gives three times the headroom of the largest measured tree with one object.

Raise the Postgres tarball cap (rejected)

  • Pros: One constant.
  • Cons: The stored cap is already 8 MiB against a 900 KiB delivery budget; raising it widens the gap between what registers and what runs., Each launch still copies the whole tarball into pack_tarballs.
  • Rejected because: Storage is not the binding limit; delivery and copies are.

ADR-0058: Tasks receive named, size-bounded inputs as files

Status: proposed | Date: 2026-10-02

References: RFC-0001:C-WORKFLOW, RFC-0001:C-WIRE, RFC-0002:C-TASK-FILES, RFC-0002:C-SETTLED-JOIN, RFC-0002:C-PLAYBOOK-PARAMS, RFC-0002:C-PLAYBOOK-RESUME, RFC-0004:C-CONTRACT-VERSION, ADR-0027, ADR-0071

Context

Upstream results reach a task three ways today. None is named by the consumer and only one is bounded.

  • Command and evaluate tasks get one JSON object holding every direct dependency's whole output plus the reserved inputs, params included. ShellRunner writes it to a temp file named by CRUCIBLE_INPUTS_FILE and also sets CRUCIBLE_INPUTS when it is at most 64 KiB (crucible/src/plan/runner.rs:24-30, :111-114). The file exists because Linux refuses any single environment string over 128 KiB, and a wide fan-out folded into one consumer produced a larger object. It fixes spawn and changes nothing about what is delivered: a consumer that needs one field of one producer still receives and parses everything.
  • Agent tasks get the same object minus params, which agents never receive (RFC-0002:C-PLAYBOOK-PARAMS, crucible/src/plan/exec.rs:1497-1512), pretty-printed inside the prompt (crucible/src/plan/harness.rs:610-629). The sandbox path sends the prompt over stdin (crucible/src/openshell/run.rs:693-698). The local claude path passes it as one argv element (crucible/src/agent/harness/claude.rs:118-122, :130-136), as does local hermes (crucible/src/agent/harness/hermes.rs:107-113), and a single argv string has the same 128 KiB limit. The --agent-cmd stand-in gets the prompt plus history in CRUCIBLE_PROMPT and the inputs in the same two variables as a command (runner.rs:111-114, :143-149). Every agent path pays input tokens for every upstream byte on every turn, read or not.
  • Declared files (emits_files) are staged read-only under inputs/<producer>/ for every ancestor (harness.rs:631-665, RFC-0002:C-TASK-FILES). This is the bulk channel. Its run total is bounded by a constant (harness.rs:37-39).

Declared JSON fields have names and optional types (RFC-0001:C-WORKFLOW, crucible/src/plan/ir.rs:469-473); nothing bounds their size. A consumer of a fan-out fold receives every instance's whole output, so its input grows with the fan-out width times whatever each instance chose to return.

Producer names become paths without escaping, and that is a path escape today, not only a collision:

  • Task names are refused only when empty or bracketed (ir.rs:1072-1080). A task named .. captures at state/files/.. and stages at inputs/.., which is the workspace root (harness.rs:76-82, :643-656). A task named /abs/path makes Path::join discard the base, so its files are captured and staged outside the run's state and workspace. A name containing / nests: task a/b stages at the same path as task a's declared file b/....
  • Fan-out keys are any non-empty, bracket-free, unique string (crucible/src/plan/exec.rs:1655-1667), and an instance's files are captured and staged under task[key] (exec.rs:1417-1418, RFC-0002:C-SETTLED-JOIN). A key such as a/../../x makes that path climb out of inputs/.

Constraints on changing delivery:

  • Packs are stored as source, with the controller holding pins (ADR-0027), and compiled per run (crucible/src/manifest/mod.rs:1105-1131). The registry pins a pack by its source tarball digest (crucible-controller/src/playbooks/registry.rs:715), keeps an engine-derived params schema digest and exposure digest, and records the engine revision (core_rev) they were derived with (registry.rs:780-786, crucible-controller/src/playbooks/exposure.rs:239). A standing launch is bound to the schema digest and refused when it drifts (crucible-controller/src/launches/store.rs:380-390). When the linked engine changes, rederive_stale re-extracts the schema and exposure of every row and re-stamps core_rev without asking anyone (registry.rs:1024-1080). So an engine bump re-derives what a pack asks for (its form) and what it can reach (its declared outputs and capabilities), and a schema change is visible to standing launches. None of these covers the compiled plan: an engine release that changes how an unchanged pack's tasks receive data changes an approved pack's behaviour while every stored digest stays the same.
  • Resume recompiles and refuses when the plan digest differs from the one the run started with (crucible/src/plan/resume.rs:30-38, :100-107). An engine upgrade that changes the compiled bytes of an unchanged pack breaks resume of every in-flight run.
  • Nothing chooses the plan IR version; version: 1 is written at every site that builds a plan (crucible/src/plan/cli.rs:666-667, crucible/src/plan/template.rs:21, :101, crucible/src/runloop/graph.rs:196, :1072, crucible/src/scope/pipeline.rs:1054, and the validation copy at crucible/src/plan/workflow.rs:337-338), and anything else is refused (ir.rs:1034-1038).
  • Which paths drop an unknown task field differs. Task does not deny unknown fields (ir.rs:443-501), so [[workflow.task]] tables in a manifest and plan files read by Plan::from_toml_str or Plan::from_json_str (cli.rs:58-65) lose a field an older engine does not know. A Starlark source does not: each constructor checks its kwargs against an allowlist (crucible/src/plan/starlark.rs:600-643), so an older engine fails to compile an unknown kwarg. WorkflowCfg denies unknown fields (workflow.rs:245-246), so an unknown [workflow] key is refused on every path.

Decision

Follow Tekton's split of params, results, workspaces, and artifacts: a task names the small values it reads, those travel as files by name, and bulk data travels as declared files. A pack opts in with an edition (ADR-0071); an unchanged pack compiles and runs as it does today.

Declared inputs. A task declares inputs, a map from an input name to producer.field. Validation refuses an input whose producer is not a direct dependency or does not declare that field, and the input carries the field's declared type. JSON outputs keep the direct-dependency rule of RFC-0002:C-TASK-FILES. A task receives no upstream value it did not declare; the reserved inputs it receives today (params for command and evaluate tasks only, item, history, revision, kept, outcome) arrive the same way under their own names.

Staging. Before dispatch the engine writes each input as its own read-only file in a reserved directory beside the producer directories materialize_inputs lays down, one file per instance for a mapped producer's fold, with an index giving each input's producer, field, status, note, type, size, digest, and path. One environment variable names the directory, and it replaces CRUCIBLE_INPUTS and CRUCIBLE_INPUTS_FILE for every task in the pack, the --agent-cmd stand-in included. A producer that did not pass, which only a task joining "settled" sees, keeps RFC-0002:C-SETTLED-JOIN's semantics: the engine stages the field from the retained failure output when that output carries it, and the index says the producer did not pass, whether a value was staged, and whether its file set was staged. A consumer cannot rely on the declared type of a failed producer's value, as RFC-0002:C-SETTLED-JOIN already says of declared fields.

Output cap. Each declared emits field's serialized value is capped at the producer. A passing attempt over the cap settles as a measured failure at the producer and is not retried, and its note names the field, its size, the cap, and emits_files. The cap is part of the engine's run configuration, set by the operator and not written into the manifest or the plan, so it never changes a plan's bytes. It bounds declared fields only: undeclared fields and the result object as a whole stay unbounded and still reach the session log, run records, history_record, and the outcome input an epilogue task receives. An oversized declared field never reaches a consumer; an oversized undeclared field reaches no consumer as an input because nothing can declare it, but it still reaches those records.

Agent prompts. The inputs section lists each input by name, type, size, and path. It inlines a value only when the value is small and the inlined total stays under a budget well below 128 KiB; the agent reads the rest from the files, which reach the sandbox with the workspace as staged files do today. History keeps its marked region.

Producer paths. Every task name and every fan-out key maps to a single path component under inputs/ and state/files/. This applies to every edition because it is an escape, not a delivery choice. Validation refuses a task name that is . or .., contains a path separator, or starts with the reserved directory's leading character; a valid name stages exactly where it does today. A fan-out key is runtime data, so the engine escapes it when it forms the instance directory instead of refusing it, and the index carries the raw key. Edition 3 adds no task-name rule of its own.

Local agent argv. The local claude and hermes paths send the prompt on stdin, as the sandbox path does; claude's print mode reads stdin when no prompt argument is given (claude.rs:56-59) and hermes accepts -q - (hermes.rs:116-121). The --agent-cmd stand-in gets its prompt in a file, with CRUCIBLE_PROMPT kept while it fits, as #210 did for inputs. This applies to every edition: it is a spawn fix that changes no plan bytes, and a pack below edition 3 should not keep a spawn failure because it has not ported.

Editions and IR versions.

  • Declared inputs are edition 3. Editions are cumulative (ADR-0071), so edition 3 also has edition 2's declared host env. edition is the manifest's top-level key, not a [workflow] key, because edition 2 governs [judge] and [world] too; WorkflowCfg carries it from the manifest. The Starlark workflow() constructor does not take it: the compiler has to know the edition before it evaluates the source, to decide whether inputs= is a known kwarg, as RFC-0001:C-WORKFLOW already requires of the lane. resolve_workflow_with replaces the manifest's WorkflowCfg with the compiled one (manifest/mod.rs:1128-1130), so it carries edition across as it carries resolved_from.
  • The edition selects the IR version: editions 1 and 2 compile to v1, edition 3 to v2. Every site that builds a plan from an author's WorkflowCfg (cli.rs:666-667, template.rs:21, graph.rs:196, pipeline.rs:1054, workflow.rs:337-338) takes the version from WorkflowCfg.edition. The engine-built graphs with no author tasks (template.rs:101, graph.rs:1072) stay v1. Edition 3 applies in every lane, the scored loop included, where kept is staged like any other reserved input.
  • v2-only fields are skipped when empty and v1 serialization does not change, so an unchanged source compiles to the same bytes on an engine that knows v2. Source rev, schema digest, exposure digest, and plan digest all stay put: no re-approval, and resume works across the upgrade.
  • An engine runs each version with that version's semantics. v1 is the behaviour after #210 plus the two fixes above. v2 is the above.
  • An engine that predates v2 refuses an edition-3 manifest at parse, naming the editions it supports (ADR-0071), and a v2 plan file with UnsupportedVersion. The version is the gate rather than the presence of inputs, because [[workflow.task]] tables and plan files would lose inputs on an older engine instead of refusing it.
  • A run keeps the IR version it started with (RFC-0002:C-PLAYBOOK-RESUME folds results against the same graph). Resume recompiles the same source to the same version, and the digest check refuses anything else.

Specification. This record chooses the design; it does not define the contract. Before it is accepted, these land as normative clauses: a new RFC-0002 clause beside RFC-0002:C-TASK-FILES for the inputs directory, its environment variable, the file and index layout, key escaping, the cap's default and failure, and the inline budget; amendments to RFC-0002:C-SETTLED-JOIN and RFC-0002:C-PLAYBOOK-PARAMS scoping their delivery rules by edition and stating the settled-join staging above; an amendment to RFC-0001:C-WORKFLOW for inputs and the producer-name rule, with edition 3 added to the editions RFC-0001:C-COMMANDS defines; and an amendment to RFC-0001:C-WIRE if plan_admitted is to carry each task's inputs (it already carries plan_version). The numbers this record does not fix (cap default, inline thresholds) are set there.

Compatibility. Every checked-in pack compiles byte-identical before and after the engine change, and that is tested. The engine keeps v1 while any pack or resumable run uses it; a warning below edition 3 is a later decision, beside the edition-1 warning ADR-0071 adds.

Consequences

  • A consumer's input is what it named, so a fan-out's width costs a consumer one directory, not one environment string or one prompt.
  • Agent prompts stop growing with upstream output. An agent that needs a large value reads a file, which costs a tool call where it used to cost prompt tokens on every turn.
  • A producer that returns a large blob in a declared JSON field fails under edition 3 with a note pointing at emits_files. Below edition 3 nothing changes.
  • The cap does not bound undeclared fields or whole result objects, so run records, history_record, and the outcome input stay as large as producers make them.
  • Edition 3 needs a declaration per value read, and every consumer in a pack is ported when the pack opts in. A consumer that forgets one finds the file absent at run time; validation cannot see what a command or prompt reads.
  • A settled consumer under edition 3 receives the fields it declared from a failed producer's retained output, not the whole retained output. RFC-0002:C-SETTLED-JOIN is amended to say so.
  • The index gives a consumer size and digest before it reads, and gives a run record what each task was handed.
  • The engine carries two input paths until no pack or resumable run uses v1.
  • Editions 1 and 2 change in two places. A pack whose task name is ., .., contains a separator, or starts with the reserved character stops validating, and an in-flight run of such a pack cannot resume. An instance whose key contains a separator or starts with . stages and captures under an escaped directory, so a consumer that hard-coded the nested path breaks. Both were path escapes.
  • Local agent turns no longer fail to spawn on large prompts in any edition; the prompt's token cost below edition 3 is unchanged.
  • A dispatch image older than the edition is refused at parse, not degraded. RFC-0004:C-CONTRACT-VERSION already keeps dispatch images in step with the linked engine; this adds no second check.
  • The direct-dependency rule for JSON outputs is unchanged. Reading a value from a grandparent still means the parent re-emits it or the producer writes a file.

Alternatives Considered

Named, typed inputs staged as files with a producer-side cap on declared fields, gated by a pack edition that selects the IR version (accepted)

  • Pros: A consumer receives only what it declared, so input size tracks what it reads., Large values stay out of the environment and out of prompts; inlining is bounded in total., One manifest key tells an author or reviewer how every task in the pack is fed., Unchanged packs compile byte-identical, so no re-approval and no broken resume.
  • Cons: Two input paths in the engine until v1 is retired., Opting in means declaring every input of every consumer at once., The producer-path fix and the local stdin fix change behaviour below edition 3 for packs that relied on the old paths.

Per-task opt-in: a task that declares inputs gets file delivery, one that does not keeps the whole object, and the plan is v2 when any task declares inputs (rejected)

  • Pros: A pack ports one consumer at a time., Same gate as the edition: a plan with inputs is v2, which an older engine refuses., The plan's version is the lowest that expresses it.
  • Cons: Every undeclared consumer keeps the whole object in its environment and prompt, so the width problem stays the default for any task its author did not touch., An absent declaration and an empty one mean different things: no inputs means the whole object, so a task that wants nothing cannot say so without a separate marker., If the cap is gated by version, adding one declaration anywhere makes an unrelated producer fail on a field it has always returned; if it is not, edition-1 packs change behaviour on upgrade., Which delivery a task gets is a per-task fact a reviewer has to read off every task.
  • Rejected because: Its benefit is incremental porting, and the cost is that the whole-object path stays live for every task an author forgets. The edition makes declared delivery a property of the pack and gives the cap one meaning per pack.

Stop at #210: keep the whole-object file and the bounded env copy (rejected)

  • Pros: Already shipped; no pack changes.
  • Cons: Every consumer still parses every upstream output., Agent prompts still inline everything., Emit fields stay unbounded.
  • Rejected because: It fixes spawn for commands only. The argv and path-escape fixes do not need this decision; the size and prompt cost do.

Change input delivery for every pack in one engine release, with no edition (rejected)

  • Pros: One input path.
  • Cons: Every pack's compiled plan changes on upgrade, so every in-flight run fails resume with PlanChanged., Approved packs change behaviour without a source change or a re-approval, and no stored digest moves., Every consumer reading CRUCIBLE_INPUTS breaks at once.
  • Rejected because: Breaks running work and approved behaviour in exchange for not carrying v1.

Add inputs as a serde-default task field under IR version 1 (rejected)

  • Pros: No version bump.
  • Cons: Starlark packs would fail to compile on an older engine, but [[workflow.task]] tables and plan files read by Plan::from_toml_str or Plan::from_json_str would lose inputs and run with v1 delivery while the author expects v2.
  • Rejected because: Silent downgrade on the TOML and plan-file paths; the version has to be the gate.

Pass large values by reference to an external artifact store (Tekton artifacts, Argo S3 artifacts) (rejected)

  • Pros: No size limit on values.
  • Cons: A new storage dependency for data that already fits emits_files inside the run., RFC-0002:C-TASK-FILES keeps in-run files from becoming a channel between runs; an external store would need the same rule enforced elsewhere.
  • Rejected because: emits_files is already the bulk channel inside a run; the gap is naming and bounding, not storage.

Feed a command its inputs on stdin (rejected)

  • Pros: No temp files.
  • Cons: Takes stdin from commands that read their own input., Readable once, still the whole object, and does nothing for agent prompts.
  • Rejected because: Moves the blob to another stream without making it smaller or named.

ADR-0059: Packs are flattened and stored by tree digest, and may be published to an OCI registry

Status: accepted | Date: 2026-10-03

References: ADR-0057, RFC-0002:C-PACK-BASE, RFC-0003:C-APPROVALS, RFC-0004:C-LINKED-RENDER, RFC-0002:C-PACK-PUBLISH, RFC-0003:C-READ-SCOPING

Context

ADR-0057 settled three problems with how a playbook pack reaches a run: the delivery ConfigMap's 900 KiB budget counted raw bytes while storage counted gzipped ones, every table and every launch held its own copy of the tarball, and a pack with a large shared core and small per-variant overlays had to be copied whole into one draft per variant. It chose Postgres storage by digest, one gzipped ConfigMap key, and base packs whose identity is the pair (base digest, overlay digest), and it deferred OCI distribution. The gzipped delivery is implemented on an open change (pack_delivery_tarball); storage and base packs are not.

That ADR was reviewed only against the codebase. A survey of how other systems ship small file trees (40 projects, saved with the handoff notes as research/pack-distribution-prior-art.json) changes two of its choices and reopens OCI:

  • Systems that reuse a shared base pin one identity for the composed result. OPA's control-plane stacks, Zarf skeletons, and Flux's ArtifactGenerator flatten a base and an overlay at build time and pin the flattened artifact; Argo CD pins one git commit over a Kustomize base and overlay. Dagster's split dependency and source PEX is the closest two-key design, and OPA's run-time multi-bundle layering is where its overlap bugs are (open-policy-agent/opa#6313). No surveyed system makes every reader compose a pair of digests.
  • Hash the tree, not the archive. A digest over gzip bytes changes when compression does. Terraform added the h1: tree hash beside zh: for that reason, Go's h1: is a directory hash, and Nix's recursive hashes survived GitHub's 2023-01-30 gzip change while archive hashes broke. Crucible's content_digest is SHA-256 of gzip bytes (crucible-contract/src/artifact.rs:19).
  • OCI's strengths are distribution, not storage. Registries give a standard pull (oras, crane, skopeo), mirroring and air-gap tooling, tags people can read, per-node caching, signing (cosign, Notation), and Kubernetes image volumes, which mount an artifact with the kubelet's own pull path (KEP-4639: on by default in 1.35, GA in 1.36; CRI-O mounts non-image artifacts, containerd does not yet, containerd/containerd#11381). OpenTofu 1.10 added OCI for modules and providers for mirroring, not integrity.
  • OCI's weaknesses fall on the paths crucible uses most. Each push mints a manifest; registries collect untagged manifests on their own schedules (Quay's hidden one-hour tag for digest-only pushes, PROJQUAY-12886; Harbor's delete-untagged GC, goharbor/harbor#23570; ECR lifecycle rules), so a digest pin also needs a tag that never expires. No registry lists or searches artifacts, so the catalog, approvals and history stay in Postgres regardless. Pulling from inside workload pods is the least-used shape: Flux, Tekton, Crossplane and OPA pull once in a controller and hand content on; Kubewarden pulls per pod, and its documentation shows registry availability and pull credentials on the critical path of every restart. An init container running a registry client also ignores the runtime's mirror configuration (ImageDigestMirrorSet, registries.conf).

Base packs are a requirement, not an optimization: component teams own their overlays of shared tooling, including their own launch parameters and defaults, while the team that owns the tooling owns the base. Parameters can only be declared in the workflow source a run names, never in a loaded library (RFC-0002:C-PLAYBOOK-PARAMS), and load() resolves within the pack. So the base carries the shared library, tools, and prompts, and each overlay carries a short workflow.star that declares its parameters and calls the library.

The measured pack from ADR-0057 still holds: a shared core of 212 KiB gzipped, one variant about 222 KiB, all seven variants 297 KiB, all under the 900 KiB delivery budget.

The first concrete consumer of an OCI copy is delivery on CRI-O clusters, which include MPP: an image volume lets the kubelet pull and verify a pack by digest with the cluster's own pull path, with no ConfigMap and no registry client in the run pod. Mirroring packs to other clusters with existing tooling is the second. Neither is needed while every pack fits the ConfigMap, so publication is opt-in.

Decision

Postgres stays the record and the store of pack bytes, and a pack built on a base is flattened into one tree with one digest. Publishing packs to an OCI registry is opt-in: off unless a pack registry is configured, and never on the path of a save, a launch, or a dispatch.

Identity: a tree hash. A pack's digest is written tree1:<hex>: SHA-256 over one line per pack file, in byte-wise order of path, each line the lowercase hex SHA-256 of the file's bytes, two spaces, and the file's path relative to the pack root with / separators, ending in a newline. This is Go's h1: directory hash with a distinct prefix. It does not depend on any archive format, compression, file mode, or timestamp; no file mode is part of a pack, and every pack file is delivered and published with mode 0755, as runs receive them today. Mode is left out because a draft edited in the browser has none and every pack script must run; and the 1 names the encoding so a later change is a new prefix rather than a silent rewrite. A path must be valid UTF-8 with no empty, ., or .. segment and no newline; only regular files are pack files, and symlinks are refused. pack_delivery_tarball and every other encoding are derived from the tree, not part of its identity.

Storage: one table of trees keyed by digest. Pack bytes move to one table keyed by tree digest and are stored once. Drafts, draft versions, registered packs, imports, adopted standing launches, and launches reference the digest instead of holding a copy. A stored tree is immutable and is deleted only when nothing references it. Every draft save lands here.

Base packs: flattened at save. A draft version may name a base by its tree digest. Saving it places the overlay's files over the base's by path, an overlay file replacing a base file at the same path, and stores the result under its own digest. The base digest, the overlay's own tree digest, and which overlay paths replaced base paths are recorded on the version as provenance (RFC-0002:C-PACK-BASE); they keep no stored tree alive. Compilation, exposure extraction, approval, launch, and delivery see only the flattened tree and its digest. Moving to a newer base is a new save.

Flattening copies the base's files into the saved pack. Anyone who may read the saved pack reads those files, and approving it needs no access to the base. ADR-0057 withheld the composed tree and the approval from subjects who could not read the base; this decision drops that. Naming a base is a decision to disclose its contents to the derived pack's readers, and the save-time check that the saver may read the base is the only authorization applied to them.

Delivery. Runs receive the flattened tree as one gzipped ConfigMap key, as built by pack_delivery_tarball, within the delivery budget. Where a dispatch target's runtime mounts OCI artifacts (CRI-O) and the pack is published, a launch may instead mount the artifact at its manifest digest as an image volume; pack-stage copies it into the domain emptyDir as it does the ConfigMap. Whether a target mounts artifacts is a setting on its cluster entry. If packs outgrow the ConfigMap, the controller serves a tree by digest over in-cluster HTTP and pack-stage fetches and verifies it. A run pod never runs its own registry client.

OCI publication (opt-in). When a pack registry is configured, registering, importing, or approving a draft graduation pushes the flattened tree as an OCI artifact: the OCI empty config, application/vnd.oci.image.layer.v1.tar+gzip layers, and the pack type and tree digest in fixed manifest annotations. A pack saved on a base gets two layers, the base's full tree and then the overlay's files, so later files replace earlier ones on extraction; layers are written by one fixed encoder (sorted entries, zeroed metadata, fixed gzip level and header) so every pack on one base carries a byte-identical first layer. The empty config makes this an artifact, which only CRI-O mounts today; that is accepted, since CRI-O is the first consumer. All packs go to one repository under a tag derived from the tree digest, which is unique per tree, never moves, and survives transfers and renames; when two packs share a digest, the first publication's manifest serves both. Each version carries a publication state (unpublished, pending, published with the manifest digest, failed with the error), and transient failures are retried with backoff.

Registry access. Pull credentials go to the controller and to dispatch targets the platform owns or a platform administrator marks registry-trusted (an audited mark no one else can set), never to a user or team; a brought cluster owned by a team gets controller-built delivery only, since one repository holds every pack. The registry is a delivery and mirroring channel, not a read path; people read packs through the controller, under RFC-0003:C-READ-SCOPING.

Approvals. A draft graduation's approval binds that draft and the tree digest it approved, as a Postgres row. Signing published manifests with cosign is a later decision.

Migration. Every stored tarball's tree digest is computed from its stored bytes, and every pin, approval, base reference, and run-history record holding the old gzip digest is rewritten to it; the old digest is kept as a recorded alias, and a request naming an old digest is refused as superseded.

Specification. RFC-0002:C-PACK-BASE and RFC-0003:C-APPROVALS as written for ADR-0057 are amended to this decision, and RFC-0002:C-PACK-PUBLISH states the publication obligations.

Consequences

  • A launch, a standing launch, and an approval each name one digest that covers the whole tree, base included. No reader composes anything after a save.
  • A base that later becomes unreadable or deleted does not affect packs already saved on it. A new save on that base is refused.
  • Saving on a base discloses the base's files to everyone who may read, share, launch, or approve the derived pack. ADR-0057's read isolation for base content is gone. For shared tooling meant to be shared, this is the intent.
  • The tree digest is independent of tar format, gzip, and file modes. Changing its encoding is a new prefix and a migration.
  • Storage stops growing by one tarball per launch, but each variant's flattened tree holds its own copy of a shared core in Postgres. At 222 KiB per variant this is accepted.
  • With no pack registry configured, nothing changes for saves, launches, or dispatch. With one configured, the controller needs a push credential and each mounting cluster a pull secret, and pack contents sit in a registry that only the controller and clusters can read.
  • Image-volume mounts work only on CRI-O while the artifact uses the empty config; containerd clusters keep the ConfigMap.
  • Base-layer sharing in the registry and on nodes depends on the fixed layer encoder. Changing it breaks sharing but not identity.
  • If the controller-served HTTP fallback is used, pod start depends on the controller being reachable, which today it does not.
  • Every published tree keeps its tag until a retention decision removes tags whose digests nothing references.
  • The migration rewrites the digests that approvals and pins bind. Digests already copied outside the controller (run records, links) keep the old value; a request using one is refused with its replacement named.
  • A fix to the base reaches a component team's pack only when the team saves on the new base revision and the result is approved again. The controller reports when a version's base has a newer revision, so a rebase is one save, but nothing propagates on its own.
  • A component team owns its parameters by owning its overlay's workflow.star; shared workflow logic belongs in a library the base provides.

Alternatives Considered

Postgres stores flattened trees by tree digest; publication to an OCI registry is opt-in and feeds CRI-O image volumes and mirroring; runs get the ConfigMap otherwise (accepted)

  • Pros: Saves, launches, and dispatch have no registry dependency, One digest covers the whole tree, so pins and approvals compare one value, Published packs gain standard pull, mirroring, tags, and image-volume mounting
  • Cons: Two stores: Postgres for everything, the registry for published versions, Each variant's flattened tree stores its own copy of a shared core in Postgres, Saving on a base discloses the base's files to the derived pack's readers

OCI as the store of record: every draft save pushes an artifact, and pack-stage pulls by digest from the registry (rejected)

  • Pros: One store and one delivery path for drafts and published packs, Layer reuse deduplicates a shared base everywhere
  • Cons: A registry on every draft save and every pod start; an outage blocks editing and launching, Every save leaves a manifest that registries garbage-collect on their own schedules, so pins need never-expiring tags, Credentials and a registry client in every run pod, bypassing the runtime's mirror configuration, The catalog, approvals, and history stay in Postgres regardless, since registries do not list or search

ADR-0057 as accepted: a base is controller metadata, identity is the (base, overlay) digest pair, and every reader composes the pair; no OCI (rejected)

  • Pros: A shared core is stored once in Postgres, No registry at all
  • Cons: Every reader composes at read time and every comparison must compare two digests; no surveyed system composes trees at run time, Gives up OCI tooling, mirroring, and image volumes

Controller-served HTTP as the only transport beyond the ConfigMap: pack-stage fetches GET /packs/ from the controller and verifies it; no OCI

  • Pros: Removes the ConfigMap ceiling with no new infrastructure or credentials
  • Cons: Run pods depend on the controller being reachable at start, No standard pull, mirroring, or signing for other consumers

Identity as a canonical tar digest (SHA-256 over a fully specified uncompressed tar), so it can equal an OCI layer diff_id (rejected)

  • Pros: The hashed bytes are the bytes delivered and published
  • Cons: Reproducing it needs the exact tar format, header fields, and long-path encoding; two tar libraries rarely agree, An identity tied to an archive format is the archive-hash problem the tree hash avoids

ADR-0060: Schema-typed emits carry the schema text in the plan

Status: accepted | Date: 2026-10-05

References: RFC-0001:C-WORKFLOW, RFC-0002:C-TASK-FILES, RFC-0004:C-CONTRACT-VERSION, ADR-0058

Context

Typed emits (RFC-0001:C-WORKFLOW) promise a field's JSON type or a label set, and nothing about the shape inside a list or object. A declared file (RFC-0002:C-TASK-FILES) promises only that it exists. A task handing structured data downstream (a planner's lane list, a probe's RESULT.json) cannot say what that data looks like, so a malformed result fails in whichever consumer reads it.

Constraints:

  • The generated [workflow] TOML is the runtime authority, and the compiled plan's bytes are its digest. Resume refuses a changed digest, and an approval pins the plan it saw. Whatever the schema check enforces has to be in those bytes, not behind a path.
  • TOML has no null. JSON Schemas carry null routinely: "default": null, "const": null, "enum": [..., null].
  • Task does not deny unknown fields (ADR-0058), so a plan file or [[workflow.task]] table read by an older engine drops a field it does not know and runs without it.
  • Consumer checks (over needs a list, score readers a number, a noul route a boolean) match on the coarse field type.
  • The controller already validates launch params with the jsonschema crate, built without default features.

Decision

Field type. FieldType::Schema(JsonSchema) joins the field types. JsonSchema::new is its only constructor and refuses a $schema other than 2020-12 and any document the jsonschema crate will not compile. The crate is built without default features, so a remote $ref fails compilation rather than being fetched. Validation lives in the contract crate, so the engine and the controller judge the same documents.

DSL. schema_file("path") reads the schema from the pack under load()'s confinement, bounded and charged to the prompt budget, and is accepted as a type in the dict form of emits and as a value in a new dict form of emits_files ({"RESULT.json": schema_file(...), "REPORT.md": None}). The list form of emits_files stays.

Wire. A schema is carried as its JSON text: {"schema": "<text>"} for a field type, {"path": ..., "schema": "<text>"} for a declared file. A declared file without a schema stays a bare path string. The text is the document serde_json re-serializes, so the plan pins the content and an edit to the schema file changes the digest.

Consumers. A schema satisfies each built-in consumer exactly, read without evaluating it: over needs type: array with items that are provably strings (by type, const, or enum, through local $ref, allOf, or every anyOf/oneOf branch), since the runtime names an instance by its item; a score needs a numeric type and no non-number in its const or enum; a route needs every value the schema bounds (const, enum, or type: boolean) to be an answer the question accepts. A when label such a source can never give is refused. A schema with no single top-level type satisfies none of them.

Notes. A refusal gives the first three errors as instance path and message, masked so the note does not repeat the refused value; the note is persisted and served and the output is not.

Compatibility. Every existing plan serializes byte-identical. An older engine refuses a schema field type (an unknown map form of FieldType) and a schema-typed file (a table where Vec<String> expects a string) instead of dropping either. The contract version moves to 1.14.0 because plan_admitted gains emits_files and both wire forms gain a variant (RFC-0004:C-CONTRACT-VERSION).

Consequences

  • A producer's output and declared JSON files can be held to a shape, and a mismatch fails at the producer with a note naming where.
  • Plans that use schemas are larger by the schema text, and plan_admitted carries it per task. schema_file() is bounded at 64 KiB per file and shares the embed budget with prompts.
  • The schema text in TOML and JSON is a string, so a reader of the generated manifest or the event parses it a second time to see the document.
  • The compile-time reading is syntactic: it proves what type, const, enum, local $ref, allOf, anyOf and oneOf say and refuses anything it cannot prove, so a schema that constrains its values another way (pattern, not, if/then) must also state them in one of those keywords to satisfy a consumer. It does not check that a mapped item's string is a usable key (non-empty, no brackets, unique); the runtime still refuses those.
  • Validation compiles the schema per check. It runs once per passing attempt per typed field or file, which is cheap next to the task itself.
  • format is an annotation under 2020-12 and is not asserted, as the crate defaults.
  • An older engine cannot run a plan that uses schemas. RFC-0004:C-CONTRACT-VERSION already keeps dispatch images in step with the controller.

Alternatives Considered

Carry the schema as its JSON text, and make schema-typed files a table entry in emits_files (accepted)

  • Pros: Any valid schema survives the TOML authority, nulls included., Existing plans keep their bytes; an older engine refuses a schema it cannot enforce., One argument names each declared file once.
  • Cons: Readers parse the schema text a second time., emits_files entries are no longer all strings, so every reader of the compiled plan has to take the table form.

Carry the schema as a nested JSON object, and refuse a schema holding null (rejected)

  • Pros: Readable in the TOML and in the event without a second parse.
  • Cons: Refuses common schemas, including what generators such as pydantic emit for optional fields.
  • Rejected because: An arbitrary hole in which valid schemas are accepted is worse than a string field.

Pin the schema by path and digest, and read the file at run time (rejected)

  • Pros: Small plans.
  • Cons: The schema is not in the hashed plan, so what was approved is not what is enforced unless every reader re-checks the digest., Runs would need the pack tree to validate output; resume and the controller would need it too.
  • Rejected because: The plan is the authority; enforcement must not depend on a file beside it.

A parallel files_schema task field keyed by path (rejected)

  • Pros: emits_files keeps one type.
  • Cons: An older engine drops the unknown field from a plan file and runs without validating., Two arguments keyed by the same paths need their own consistency checks.
  • Rejected because: Silent downgrade on the plan-file path, and duplicated keys.

Derive consumer compatibility by checking the schema against a canonical schema for each consumer (rejected)

  • Pros: Could check item shapes for over.
  • Cons: Schema subsumption is undecidable in general and the crate offers no such check.
  • Rejected because: Schema subsumption has no library to stand on; the keyword reading each consumer needs is small and exact.

ADR-0061: Repair resumes the agent's session

Status: accepted | Date: 2026-10-05

References: RFC-0001:C-WORKFLOW, RFC-0002:C-TASK-FILES, RFC-0002:C-REVISE-LOOP, ADR-0060

Context

A passing agent turn whose output misses a declared field, holds one of the wrong type, or breaks a schema (ADR-0060) settles as a measured failure and is not retried (RFC-0001:C-WORKFLOW). The failure is usually small (a number written as a string, a missing key) and the agent that made it is the one best placed to fix it: it has the inputs, the work, and the files in front of it.

What exists today:

  • Transport retries re-dispatch the attempt from scratch. They are for infrastructure failures and never apply to a measured one.
  • A revise loop (RFC-0002:C-REVISE-LOOP) sends a reviewer's failing verdict back to the tasks it names. It needs a second task, re-runs whole tasks with the verdict as an input, and refuses a task that maps over a list.
  • Named sessions keep one conversation across tasks through a ledger of provider ids, and the claude harness and the command backend start or resume a conversation by id.
  • The executor validates emits after the runner returns; the harness validates declared files while capturing them, and an isolated task's worktree is gone once the runner returns.

Decision

An agent task may declare repair = N (0 to 3). When a passing turn's output breaks its declared emits or files, the harness resumes the same conversation with the masked validation notes and asks for the output and files to be fixed in place, then validates again, up to N times, inside the same attempt.

  • Where. The loop runs in the harness, after the turn and before capture, in whichever root the attempt runs in, so an isolated worktree still holds the files being fixed. Each mapped instance is its own task to the runner and repairs on its own.
  • Which conversation. A task in a named session resumes it and advances its ledger as any turn does. A task with no session gets a conversation for the attempt: started with a known id, never written to the ledger, so a transport retry starts fresh.
  • Bounds. Repair turns share the attempt's deadline. Their cost is the attempt's cost. The executor reports what the run may still spend before each dispatch, and no repair starts once the attempt has spent that.
  • Not a retry. The attempt count does not move. A repair turn that dies on transport ends the attempt as a measured failure; one that writes no result leaves the previous result standing. When repairs run out the attempt settles exactly as it would have without them.
  • Record. Each repair turn is recorded on the task's result and task_result event with its label (analyze[RHAI-1] repair 1/2), round, cost, and notes.
  • Only agents. command and evaluate refuse the argument: they are deterministic, so a second run produces the same output.

Consequences

  • A small output mistake costs one more turn of the same conversation instead of a failed task, and the turn starts with the context that produced the mistake.
  • Mapped tasks get a fix-up path; revise cannot run over a fan-out.
  • A repair uses the conversation's context window and cost, and a harness without Crucible-managed sessions cannot run it: a task declaring repair on such a harness fails its first turn as a transport error.
  • A task's cost and duration now include its repairs, so timeout and the run budget have to allow for them.
  • Within a concurrent batch, each attempt compares its own spend to what the run had left at dispatch, so concurrent repairs can together overrun the budget by up to one turn each; the executor's overrun check after the batch still halts the run.
  • An engine that predates repair ignores it in a plan file and fails the task as before. Nothing it enforces is weakened.

Alternatives Considered

Resume the same conversation inside the attempt (accepted)

  • Pros: The agent sees its own work and the inputs it used; the repair prompt only has to say what broke., Cheaper than a fresh turn that re-reads every input., Works in isolated worktrees and per mapped instance.
  • Cons: Needs a harness with resumable sessions., A conversation that went wrong once may repeat the mistake; N bounds it.

Re-dispatch the attempt with the validation notes appended to the prompt (rejected)

  • Pros: Works on every harness.
  • Cons: Pays the whole turn again, inputs and exploration included, to fix one field., Loses the work the first turn did; an isolated attempt's files are already discarded., Reads as a retry while the executor promises measured failures are never retried.
  • Rejected because: Cost and lost context for what is usually a one-line fix.

Express it as a revise loop with a validating reviewer (rejected)

  • Pros: No new mechanism.
  • Cons: Needs a second task per producer only to validate what the engine already validates., Revise refuses mapped tasks, which are where per-item output drift is most common., Each round re-runs the whole task rather than continuing its conversation.
  • Rejected because: Revise is for a reviewer's judgment, not for the engine's own validation, and it cannot cover fan-outs.

Run the loop in the executor through a new runner call (rejected)

  • Pros: The executor already holds the budget and validates emits.
  • Cons: An isolated worktree is gone by the time the runner returns, so its files cannot be fixed in place., File validation lives in the harness's capture, which the executor cannot see.
  • Rejected because: The files and the conversation live in the harness; the executor only needs to report the budget.

ADR-0062: Per-element decisions are routes mapped over the list the gated work maps over

Status: accepted | Date: 2026-10-07

References: RFC-0001:C-ROUTE, RFC-0001:C-ROUTE-EACH, RFC-0001:C-ROUTE-FILES, RFC-0001:C-WORKFLOW, RFC-0002:C-SETTLED-JOIN, RFC-0002:C-PLAYBOOK-RESUME, RFC-0002:C-REVISE-LOOP, ADR-0064

Context

A route (RFC-0001:C-ROUTE) asks a decision model typed questions once and gates downstream tasks with when. A route cannot declare over, so a playbook that fans agent turns out over a list cannot put a decision in front of each turn: the only decision available is one for the whole run.

The motivating case is a CVE triage playbook: a command stages per-ticket facts and emits the ticket keys, an agent task maps over the keys with one turn per ticket, a command applies the verdicts and emits the keys worth fixing, and a second agent task maps over those. Most of the cost is the per-ticket turns, and the decisions that would cut it are per ticket: skip a turn when the staged facts already settle reachability, give hard tickets more effort than easy ones, gate a fix on whether the version bump keeps the API, judge whether an agent's result file is supported by its own evidence, and flag likely duplicates.

What exists today:

  • A mapped task names each instance by its item, node[key], and a resumed run keeps an instance's result by that key (RFC-0001:C-WORKFLOW).
  • A mapped dependency reaches its dependents as one folded output; a fold passes only when every instance passed.
  • A when is evaluated once per node, before the node expands.
  • Not taken is a terminal status, and an untaken branch is untaken to its leaves through "all" joins.
  • A route's state is its dependencies' JSON outputs. A task whose only output is a declared file gives a route nothing to read.
  • A revise loop refuses a body that maps over a list, and its reviewer must be an agent, command, or evaluate task (RFC-0002:C-REVISE-LOOP).

Decision

Let a route map over a list, and evaluate a when on a mapped route per element in a task that maps over the same list. Two tasks mapped over the same producer field are aligned; their instances pair by key, which is already the identity a resume relies on. The normative rules are RFC-0001:C-ROUTE-EACH and RFC-0001:C-ROUTE-FILES.

  • One decision per element. A mapped route's instance for a key is that element's decision, recorded and folded on resume like any instance, so an element is asked once per run.
  • Per-element reads. On every aligned edge, the dependency reaches an instance as its own instance for the same key, under the dependency's own name, instead of the fold. One rule covers routes, the tasks they gate, and plain per-element pipelines alike, and an untaken element stays untaken down the whole aligned chain.
  • Gating. when keeps its meaning and gains a per-element reading. Partitioning by answer is several aligned tasks with whens on the same question, and the existing coverage rule makes every label lead somewhere.
  • State. An element's state is the route's inputs plus its key. keyed narrows a per-element object field to the element's entry, for any mapped task, so a hundred-element fan-out does not hand every instance, model request or agent turn, the whole field, and one element's facts cannot sway another's answer. A route may also read declared JSON files, which is how it judges an agent whose output is a file.
  • Folds. An instance not taken is not a failure. A fan-out whose instances were all untaken is itself untaken, so its "all" dependents are untaken as any branch's are and a "settled" reducer still runs.

For the triage playbook, sketched (illustrative; the clauses above are the contract):

gate = route(name = "gate", depends_on = [prepare], over = prepare.members, max_fanout = 120,
             keyed = [prepare.facts], min_confidence = 0.8,
             questions = {"depth": choice(ask = "...", options = {"settled": "...", "quick": "...", "deep": "..."},
                                          drop = ["settled"])})
quick = agent(name = "quick", effort = "low", depends_on = [prepare, gate], over = prepare.members,
              max_fanout = 120, when = gate.depth, answers = "quick", ...)
deep = agent(name = "deep", effort = "high", depends_on = [prepare, gate], over = prepare.members,
             max_fanout = 120, when = gate.depth, otherwise = True, ...)   # deep and uncertain
judge = route(name = "judge", depends_on = [quick, deep], join = "settled", over = prepare.members,
              max_fanout = 120, files = ["RESULT.json"], min_confidence = 0.8,
              questions = {"supported": noul(ask = "Does the evidence support the verdict?", drop = ["yes"])})
review = agent(name = "review", depends_on = [judge], over = prepare.members, max_fanout = 120,
               when = judge.supported, otherwise = True, ...)   # no and uncertain

A ticket answered "settled" costs one decision and no turn. A deterministic output-decided route can sit in front of the model-decided one when the staged facts already carry an answer; the model route then declares a when on it and is asked only about the rest. A fix gate is the same shape over the fix list.

Consequences

  • The expensive per-element turns become conditional per element, and an untaken element shows as a not-taken row under its own key rather than disappearing from the run.
  • Every element costs one decision request. max_fanout bounds the requests per run, and the run's ceilings are checked before each one. Decision requests are not charged to the run's budget: they cost little next to the turns they gate, so the ceiling check before each request is in practice a wall-clock check, and max_fanout bounds the count.
  • An aligned dependent waits for the whole mapped dependency to settle before it expands. Starting element k's turn as soon as element k's decision lands is left out; the decision is the cheap stage, so the added latency is small next to the turns it gates.
  • An aligned mapped-to-mapped edge changes what the dependent instance reads: an instance, not the fold. This is breaking for a pack whose mapped task consumes the fold of another task mapped over the same list. No published example or known pack has such an edge; a task mapped over a different list, or not mapped, still reads the fold.
  • The fan-out summary gains a not_taken count. Readers that predate it read zero, and a fold that would have failed only because instances were untaken now passes.
  • Judging an agent's result feeds a review lane, not a send-back (ADR-0064).
  • A playbook that relied on every element getting a full turn now relies on the decision model's calibration and min_confidence. uncertain still has to be routed, so the author decides what happens when the model is unsure.

Alternatives Considered

Map a route over the list, read every aligned edge per element, and read a when on a mapped route per element (accepted)

  • Pros: Reuses the instance key, the not-taken status, the coverage rule, and resume folding as they are., Every element keeps a row in every gated task, so the record shows why a turn did not run., Partitioning by answer and chaining a deterministic route in front of a model route need no new construct.
  • Cons: An aligned mapped-to-mapped edge reads an instance instead of the fold, a breaking change for any pack that relied on the fold there., Each element is a separate decision request.

Map gated tasks over a filtered list taken from the route's fold (over = gate.depth.quick) (rejected)

  • Pros: No per-element when; a gated task simply has fewer instances., Instance names stay keyed by item.
  • Cons: An element the route sent elsewhere has no row in the gated task, so a settled reducer cannot tell untaken from never listed., The coverage check would have to be rebuilt for list references instead of when labels., over would read a list the engine synthesises from a route's record, a second meaning for a field reference.
  • Rejected because: Loses the not-taken record and duplicates the coverage rule in a new place.

Quantified when on an unmapped route over the fold (any or all elements answered a label) (rejected)

  • Pros: Small change to when; useful for run-level gates such as skipping a report.
  • Cons: Still one decision for the whole list, so it cannot gate element k's turn on element k's answer., Does not address the cost driver, which is per-element turns.
  • Rejected because: Answers a different question; it can be added later on top of mapped routes if a run-level gate is needed.

One route request per run carrying every element, answered as a map keyed by element (rejected)

  • Pros: One request instead of N.
  • Cons: Neither decision API answers a question per element; the questions would have to be multiplied per key at run time, which C-ROUTE forbids because questions are compile-time., One malformed answer fails every element, and a resume re-asks all of them.
  • Rejected because: Breaks compile-time questions and per-element failure isolation.

Read aligned edges per element only along chains that involve a route (rejected)

  • Pros: Existing mapped-to-mapped edges keep reading the fold.
  • Cons: Two read rules for the same shape of edge, chosen by what is upstream., Needs a transitive per-element marking so an untaken element stays untaken past the first hop.
  • Rejected because: No known pack consumes the fold on an aligned edge, so the compatibility it buys is not worth a second rule.

ADR-0063: Score questions are ordered levels with an engine-computed score

Status: accepted | Date: 2026-10-07

References: RFC-0001:C-ROUTE, RFC-0001:C-ROUTE-SCORE, RFC-0001:C-INFERENCE, RFC-0001:C-WORKFLOW, ADR-0062

Context

A route answers noul and choice questions (RFC-0001:C-ROUTE). Some decisions are ordinal: how risky a change is, how much effort a ticket needs, how strongly evidence supports a verdict. Asked as a choice, the levels are unordered labels, so the run records which level won but not where the rest of the mass sits; "high 0.45, medium 0.40" and "high 0.45, low 0.40" record the same label.

OpenAI's Decisions API, as published in October 2026, has a native score question over ordered levels and answers it with a per-level distribution, a score, and a confidence; support for it as a decision protocol is being added beside System One. The System One API has no score question. A route's binding picks the API at launch (RFC-0001:C-INFERENCE), not the playbook, so the same playbook should run against either, and the decision is recorded once and folded on resume.

The engine already derives a choice's label from the distribution alone and ignores any choice or confidence the API reports, so the record does not depend on how a provider summarises its own answer.

Decision

Add score as a third question type: ordered levels, labelled as a choice's options are, plus a recorded score in [0, 1] that the engine computes as the expected level position over the normalized distribution (RFC-0001:C-ROUTE-SCORE). The label is the most probable level, so when, drop, otherwise, and coverage work exactly as for a choice. The engine asks it natively where the binding's API has an ordered question and as a choice where it has none, and records which under asked_as.

The engine computes the score for the same reason it derives the label: the record must mean the same thing whatever the provider reports, and the formula has to be stated for a reader to use the number. Emulating on System One rather than refusing keeps a playbook portable across bindings; a choice over the levels returns the same shape of evidence, and asked_as tells a reader comparing runs that the model was not told the levels were ordered.

Ordering work by score, such as dispatching the riskiest element's turn first so a budget halt falls on the least important, is not part of this decision. The score is in the record for a reducer or report to sort on.

Consequences

  • Ordinal judgments carry their spread, and a reducer can rank elements without a second model call.
  • A playbook runs on either binding. On system_one the model sees unordered options, so its distribution may be less calibrated across neighbouring levels than a native score; asked_as makes that visible but does not fix it.
  • The engine's score can differ from the score OpenAI reports, which may weight levels differently. Readers use the recorded score, not the provider's.
  • The score treats levels as evenly spaced, so low, medium, and high become 0, 0.5, and 1. An author whose levels are not evenly spaced should sort on the distribution, not the score.
  • Label coverage over a score question with many levels is verbose; otherwise covers the tail.
  • "score" now names both a route question type in the playbook lanes and the measured number in the scored lanes. The lanes do not share constructors, so the DSL namespace does not collide, but documentation has to keep them apart.

Alternatives Considered

Native over decisions, asked as a choice over system_one, score computed by the engine (accepted)

  • Pros: One record shape for both bindings., The score is defined by a stated formula, independent of provider summaries., No capability split; a playbook does not need to know the binding.
  • Cons: System One answers without knowing the levels are ordered.

Refuse a score question unless the binding speaks decisions, through a capability verified before dispatch (rejected)

  • Pros: Never asks a model a weaker question than the author wrote., The plan truncates before spend, so the failure is cheap and clear.
  • Cons: The same playbook fails on one deployment and runs on another., Adds a capability tier to C-PLAYBOOK-CAPS for one question type.
  • Rejected because: Portability across bindings matters more than the calibration difference, and asked_as records which form was used.

Record the score the API reports (rejected)

  • Pros: No engine formula to maintain.
  • Cons: System One reports none, so the field would be absent on one binding., Contradicts the existing rule that the label is derived from probabilities alone.
  • Rejected because: The record must not depend on how a provider summarises its own answer.

No new type; authors write a choice and compute a score in a downstream command (rejected)

  • Pros: No contract change.
  • Cons: Every playbook reimplements the same formula over an unordered distribution., OpenAI's native ordinal question is never used.
  • Rejected because: Common enough to be a type, and the native question is the better ask where it exists.

A scalar answer in [0, 1] asked directly (rejected)

  • Pros: One number, no levels to author.
  • Cons: No distribution to record, so the spread this decision exists for is lost., Neither API answers a free scalar with a probability, so min_confidence has nothing to compare.
  • Rejected because: Loses the distribution and the confidence rule.

ADR-0064: Only a route asks the decision model

Status: accepted | Date: 2026-10-07

References: RFC-0001:C-ROUTE, RFC-0001:C-INFERENCE, RFC-0001:C-ROUTE-EACH, RFC-0001:C-ROUTE-FILES, RFC-0002:C-PLAYBOOK-CAPS, RFC-0002:C-REVISE-LOOP, ADR-0062

Context

The decision binding is used by the engine's own process, and its credential must not leave the engine (RFC-0001:C-INFERENCE). A route is its only consumer today. Two pressures push against that:

  • A command task in a triage playbook already holds the per-element facts and could ask the decision model itself, through a broker tool that keeps the credential engine-side, instead of the playbook declaring a route for each question.
  • A route that judges an agent's output ("does this result file's evidence support its claim?") wants to feed its verdict back to the agent, as a reviewer in a revise loop does (RFC-0002:C-REVISE-LOOP), not only gate a later lane.

A route's guarantees come from being a graph node: its questions and labels are compile-time and in the generated TOML, every label must lead somewhere, its answer and full distribution are the task's recorded output, and a resumed run never asks again. A tool call from inside a task has none of those.

Decision

Keep every model decision a route. The engine does not relay the decision binding into any sandbox or task process and offers no tool that asks it (RFC-0001:C-INFERENCE). Per-element decisions are mapped routes (RFC-0001:C-ROUTE-EACH), and a route judges an agent's output by reading its declared JSON files (RFC-0001:C-ROUTE-FILES) and gating a review lane with when.

A judgment is not yet a send-back, and C-REVISE-LOOP does not cover it: a reviewer must be an agent, command, or evaluate task, and a body must not map over a list. Admitting a route reviewer needs a stated mapping from labels to a failing verdict, and admitting mapped bodies needs per-element rounds. Both are separate decisions, deferred until a playbook needs the judged conversation resumed rather than a second agent reviewing.

Consequences

  • Every model decision in a run is visible in the plan, covered by the label rule, recorded with its distribution, and folded on resume.
  • A command that would like a quick model opinion must be split around a route, which costs a node and an edge per question set.
  • Questions cannot be composed at run time. A playbook that needs a question per discovered category has to declare the categories up front, which is the constraint C-ROUTE already imposes.
  • The credential boundary stays simple: no sandbox egress rule, no broker tool, and no per-task decision budget to police.
  • A judged agent result that fails review goes to a separate review turn, which starts a new conversation and pays to re-read the work. Resuming the original agent's session with the judgment is the follow-up if that cost matters.

Alternatives Considered

Only route tasks ask the decision model (accepted)

  • Pros: Compile-time questions, label coverage, recorded distributions, and resume folding hold for every decision., No credential, endpoint, or tool reaches a task process.
  • Cons: A task cannot ask an ad hoc question mid-work.

A broker tool that lets a command task ask the decision model, credential kept in the engine (rejected)

  • Pros: The task asks exactly when it has the facts, with no extra node., The credential still never leaves the engine.
  • Cons: Questions and labels are authored at run time by the task, outside the plan and the coverage rule., The answer is whatever the task writes into its own output, not a recorded decision; a resume re-runs the task and asks again., Calls are unbounded per task; max_fanout and the run's ceilings do not see them., A sandboxed task would need egress or a broker channel for decision traffic.
  • Rejected because: Gives up every guarantee that makes a route auditable, for flexibility the per-element route already provides.

Declared question sets a command task may ask by name through the broker (rejected)

  • Pros: Questions stay compile-time., A task chooses when to ask.
  • Cons: The answer still lands in the task's own output rather than a decision record., Gating on it needs the task to emit it and an output-decided route to read it, which is a route again.
  • Rejected because: Ends up as a route with extra plumbing.

Admit a route as a revise reviewer, failing on listed labels (rejected)

  • Pros: A judgment can resume the judged agent's conversation.
  • Cons: Needs a label-to-verdict mapping and its own coverage rule., Useless for the per-element case until revise bodies may map over a list.
  • Rejected because: Deferred: needs a label-to-verdict mapping and per-element revise rounds, each a separate decision.

ADR-0065: The controller hands the engine its models as the inference document

Status: accepted | Date: 2026-10-07

References: RFC-0001:C-INFERENCE, RFC-0001:C-ROUTE, ADR-0036

Context

The controller's provider registry decides which model a dispatch reaches: a launch's pin, else the domain's default for its workload class, else the platform's. Until now that answer reached the engine as harness-specific environment on the pod: the key under OPENAI_API_KEY or ANTHROPIC_API_KEY, a custom endpoint under OPENAI_BASE_URL or ANTHROPIC_BASE_URL, and the Codex wire API under CRUCIBLE_INFERENCE_WIRE_API, delivered through the per-run Secret of ADR-0036.

The engine already defines one typed document for this, the resolved inference bindings of RFC-0001:C-INFERENCE, with a role per binding. The controller never wrote it. Route tasks (RFC-0001:C-ROUTE) need a decision binding, so a deployed controller could not run a model-decided route at all.

Adding a decision key the old way does not work:

  • A decision key projected as OPENAI_API_KEY collides with an OpenAI agent provider's key, and the per-run Secret refuses the collision.
  • With no agent binding, the engine reads the ambient OPENAI_API_KEY for opencode, pi and Codex, so a decision key under that name reaches the agent sandbox.

Registered OpenAI providers already hold the credential that OpenAI's Decisions API takes.

Decision

The controller renders CRUCIBLE_INFERENCE from the registry for every pod dispatch (a playbook run, a scope or rank turn, a grounded rank), with one binding per resolved role, and stops projecting harness variables.

  • Roles. A dispatch default names a role, agent or decision, alongside its scope and workload class. A decision default is accepted only for the playbook class, since autoresearch runs no route task. A launch still pins only its agent; the decision role resolves from defaults.
  • Which provider serves which role. Anthropic and Vertex serve agents through Messages. OpenAI serves agents through Responses and decisions through its Decisions API, with no URL. A custom provider serves whatever its protocol does, and system_one and decisions join the custom protocols as decision-only. A default, a fallback or a pin naming a provider that cannot serve its role is refused at the API and fails at resolution.
  • Keys. A provider that spends a secret binds key_env to the role's variable, CRUCIBLE_INFERENCE_KEY_AGENT or CRUCIBLE_INFERENCE_KEY_DECISION, which no harness reads. The engine relays the agent's key into the sandbox under the names the harness reads (RFC-0001:C-INFERENCE), and keeps the decision key in its own process. In a credentials map, the entry the protocol's clients read (OPENAI_API_KEY or ANTHROPIC_API_KEY) is the key. For an agent, the map's other entries are projected under their own names. For a decision, they are dropped. A System One secret is one bare key.
  • Local runs. The local executor reads no secret values, so it does not render the document. A local run keeps the CRUCIBLE_* environment the controller was started with, CRUCIBLE_INFERENCE included.

Consequences

  • A deployed controller can run model-decided routes on a registered OpenAI or System One provider, chosen per domain like an agent model.
  • An agent and a decision model can share one OpenAI provider and its secret: the two roles never share a variable or a Secret key.
  • Agent turns are configured by the document, as the contract intended. A pod no longer carries OPENAI_API_KEY, OPENAI_BASE_URL, ANTHROPIC_BASE_URL or CRUCIBLE_INFERENCE_WIRE_API from the registry, so a pack command that read those variables from the pod no longer sees them.
  • Dispatch images must run an engine at contract 1.15.0 or later. An older engine refuses a document with a decisions binding, and an engine older than 1.7.0 ignores the document and starts agent turns without their key.
  • A decision default on autoresearch, a per-launch decision pin, and a fallback chain longer than primary plus fallback are not available. Adding a pin later needs a column on each launch table, not a new resolution path.

Alternatives Considered

Render the inference document from the registry for every pod dispatch, keys under role variables (accepted)

  • Pros: One delivery path for both roles, through the document the engine already validates., No key lands under a name a harness reads, so a decision key never reaches a sandbox and two roles never collide., A decision provider is registered, owned, keyed and defaulted like any other provider.
  • Cons: Every dispatch image must run an engine at contract 1.15.0 or later., Pack commands lose the harness variables they could read from the pod.

Add a decision binding and keep projecting harness variables for agents (rejected)

  • Cons: Two delivery paths for one registry answer., The decision key still has to avoid every name any harness reads.

A separate decision-provider registry (rejected)

  • Cons: Duplicates the ownership, secret reference, enablement and fallback rules the providers already have.

Pin a decision model per launch now (rejected)

  • Cons: No launch needs it yet; defaults per domain cover the packs that route today.

ADR-0066: A provider's price, not the harness, costs a model no vendor bills

Status: accepted | Date: 2026-10-22

References: RFC-0001:C-INFERENCE, RFC-0002:C-PLAYBOOK-BUDGET, ADR-0065

Context

A run's cost ceiling (RFC-0002:C-PLAYBOOK-BUDGET) counts what each agent turn cost. The engine took that number from the harness: Claude Code's result cost or its OTEL roll-up, falling back to the engine's own vendor table only when the harness reported nothing. The vendor table already priced a model from neither the Anthropic nor the OpenAI family at nothing, since such a model is self-hosted and nobody bills for it.

Through a custom provider (ADR-0065) the Claude harness runs models that are not Claude, such as GLM and DeepSeek on a self-hosted endpoint. Claude Code prices those tokens at a Claude rate, so its number won over the table. One run reported $418 against a $50 ceiling and another hit its ceiling in ten minutes, on a model nobody bills for.

An operator who wants such runs bounded by a budget still needs a cost for them, real or notional.

Decision

A provider registration may carry a price: USD per million tokens for input, output, cache-read, and cache-write tokens. The controller stores it with the provider and renders it into the agent binding of the inference document. A decision binding never carries one.

The engine resolves one pricing per run from the agent binding and the model:

  • A priced binding. Each turn costs its token counts times the price, for any model, and the harness's cost is ignored.
  • No price, a vendor-billed model (an Anthropic or OpenAI name). The harness's cost stays authoritative, and the vendor table estimates a turn that reported none.
  • No price, any other model. The turn costs nothing, and the harness's cost is ignored.

Every engine site that costs a turn (the local and sandboxed turn ends, the live activity readout, and the provisional budget line) settles through that one pricing.

Consequences

  • A self-hosted model no longer trips a cost ceiling on the harness's Claude-rate guess. Without a price its runs spend nothing, so only the wall-clock ceiling bounds them.
  • An operator can budget a self-hosted model at a real or notional rate by pricing its provider, and every run on it is costed the same way whichever harness runs it.
  • A priced run's cost is only as complete as the harness's token counts. A harness that reports cost but no tokens costs nothing under a price.
  • The price rides the inference document as an additive field (contract 1.19.0). An engine older than 1.19.0 rejects a document that carries one as an unknown field, so a priced provider needs dispatch images at 1.19.0 or later. Unpriced providers render the document unchanged.
  • Scope and grounded-rank turns do not take their model from the agent binding, so they never take its price either. They are costed by the vendor table rule.

Alternatives Considered

Price a model no vendor bills at nothing by default, and let a provider carry a price that overrides the harness for any model (accepted)

  • Pros: The default matches what the vendor table already intended and what is actually billed., An operator who wants a budget to bite on a self-hosted model can have one., One pricing per run, so every cost site agrees.
  • Cons: Unpriced self-hosted runs are bounded by time alone., Older engines refuse a priced document.

Keep the harness's reported cost authoritative and only fix the default (rejected)

  • Cons: The harness's number for a non-vendor model is a guess at a rate nobody charges, so it would keep overriding the table.

Extend the engine's vendor table with self-hosted model names and rates (rejected)

  • Cons: The rate depends on who hosts the model, not on its name, so the engine cannot know it., Every new model needs an engine release.

ADR-0067: Sandboxed agents reach Kueue through a per-cluster MCP server with controller-minted per-run tokens

Status: superseded | Date: 2026-10-08 Superseded by: ADR-0068

References: ADR-0036, ADR-0053, RFC-0005

Context

Agents in OpenShell sandboxes need to run cluster work: a training Job, a RayJob, a LeaderWorkerSet, a serving Deployment, admitted and queued by Kueue. A Kubernetes credential cannot enter the sandbox, and a pack must not be able to pick the namespace or queue its run lands in.

crucible-kueue is an MCP server (rmcp, streamable HTTP, stateless) that holds the Kubernetes credentials and exposes plan/submit/status/logs/stop/resume/delete over Job, RayJob, LeaderWorkerSet, Deployment and StatefulSet, plus queue, quota and hardware views. Each request carries a bearer token whose SHA-256 maps to one tenant: a namespace, a viewer or submitter role, and a LocalQueue allowlist. Every object it creates is labelled with the tenant's run id, and mutations past submit refuse objects another run created.

What has to be decided is how a run gets a token, who decides its scope, how the server learns about it, and how the token reaches the agent without entering the sandbox.

Constraints that shape the choice:

  • The broker already solves the last step: its per-run token is a static OpenShell provider credential, the sandbox's .mcp.json carries only the openshell:resolve:env: placeholder, and the policy endpoint's credential_binding limits resolution to the broker's host and port.
  • The controller already delivers per-run secrets to the loop pod through one owner-referenced Secret (ADR-0036).
  • On a spoke the loop pod's service account holds broad rights in its own namespace; a typical spoke grants it edit in crucible-system. Anything that namespace's write rights can create, a pack command task can create, since command tasks share the run supervisor's reach.
  • ADR-0053 and RFC-0005 describe per-run supervisor services started from an attested catalog. A queue-admission front door is cluster infrastructure shared by every run on the cluster, not a per-run process.
  • MultiKueue and remote clusters are out of scope: the server runs on the cluster whose Kueue admits the work, and sandboxes reach it in-cluster.

Decision

Run one crucible-kueue Deployment per cluster, rendered from the deploy profile's [kueue] table by crucible deploy render|apply --kueue, and make the controller the only authority that grants a run access to it.

  • Scope comes from the profile. [kueue] names the cluster the server runs on, its namespace, the workload namespace every token is scoped to, the role, the queue allowlist, the tenants namespace, and the service accounts allowed to write tenant records. A run dispatched onto any other cluster gets no grant.
  • The controller mints at dispatch. For each run it draws a 32-byte token, writes a tenant record (digest, run id, scope, expiry) as a labelled ConfigMap in the tenants namespace before the pod exists, and folds the token into the run's Secret delivery as CRUCIBLE_KUEUE_TOKEN, with the Service URL as plain CRUCIBLE_KUEUE_URL. The token is never agent-visible. The record is deleted when the run is collected or its launch fails, and its expiry (the playbook's wall-clock ceiling plus an hour, else the profile TTL) bounds a record nothing deleted.
  • The server watches records. It reflects the labelled ConfigMaps of the tenants namespace beside its optional static tenants file. A record that fails validation, collides with another record's digest, or shadows a static token never authenticates; until the first list completes, no record does.
  • The tenants namespace is its own. Only the listed writers may write ConfigMaps there and the server may only read them, so a loop pod's namespace rights never extend to minting tenants.
  • The engine binds the token like the broker's. When both variables are present it registers a static crucible-kueue provider holding the token, adds a policy endpoint for the Service host and port with protocol mcp and a credential_binding to that provider, and seeds .mcp.json with a kueue server whose Authorization header is the provider's placeholder. One variable without the other fails the turn.
  • RBAC is split by scope. Cluster-scoped reads (ClusterQueues, ResourceFlavors, nodes) are bound cluster-wide; namespaced workload verbs come from a ClusterRole bound only in the workload namespace.

Consequences

  • A sandboxed agent submits cluster work with no Kubernetes credential in the sandbox, and the namespace, role and queues it gets are the operator's, not the pack's. The token resolves only on egress to the Service, so a leaked placeholder is useless and a leaked token is confined to one namespace and expires with its run.
  • Every run the controller dispatches onto the configured cluster gets the grant, whether or not a task needs it. Scoping the grant to the tasks that declare a need, as RFC-0005 does for supervisor services, needs a manifest-level declaration that does not exist yet. Until then the grant is disclosed by the profile rather than by the pack.
  • Pack command tasks run with the loop pod's environment and can read the raw token. That is no wider than the reach command tasks already have under ADR-0053; the boundary this design draws is the sandbox.
  • The server's service account can create workloads in the workload namespace. If the server runs in a namespace where loop pods hold edit, as crucible-system typically does, a pack command task can start a pod under that service account and borrow those rights. Running the server in a namespace of its own closes this; the profile's namespace knob exists for that, and a deployment that keeps the server in crucible-system accepts the exposure.
  • Resources the agent creates through the server are bounded by Kueue quota, not by the run's cost ceiling.
  • The tenants namespace and its RBAC are new deployment surface the operator owns. A controller-signed per-run JWT would remove the records and the watch entirely, at the cost of key distribution to every server; it remains the planned replacement once more than one server consumes grants.
  • A crucible endpoint spec now accepts protocol mcp, where full access means every known MCP method, because OpenShell rejects the generic method wildcard on MCP endpoints.

Alternatives Considered

Per-cluster MCP server; the controller mints per-run tokens and records their digests in a controller-only namespace the server watches (accepted)

  • Pros: Scope is set by the operator's profile, never by the pack, Reuses the broker's placeholder and credential_binding hardening unchanged, Reuses the per-run Secret delivery and its pod-owned cleanup, The server stays stateless; records are plain Kubernetes objects an operator can inspect
  • Cons: A separate namespace and RBAC to deploy, Records can outlive a controller crash until they expire, Every run on the cluster is granted, not only tasks that need it

The engine mints the token in the loop pod and writes the tenant record itself, as it does the broker token (rejected)

  • Pros: No controller change, Works for runs launched without a controller
  • Cons: The loop pod would need write access to the records, so a pack command task could mint itself any namespace, Scope would be chosen by the least trusted party in the run
  • Rejected because: It hands tenant creation to the process that runs pack code.

Run crucible-kueue as a per-run supervisor service under RFC-0005 (rejected)

  • Pros: Credential held only for runs that declare the service, Fits the catalog and needs model already drafted
  • Cons: Every run would carry a cluster-admin-shaped Kubernetes credential in its supervisor, One process per run for what is shared cluster infrastructure, RFC-0005 is a draft with no implementation to build on
  • Rejected because: Queue admission is shared cluster infrastructure; a per-run process would multiply the credential it exists to contain.

Controller-signed per-run JWTs the server verifies with the controller's public key (rejected)

  • Pros: No tenant records, watch, or tenants namespace, Scope travels in the signed claims
  • Cons: Key distribution and rotation to every server, Revocation before expiry needs a deny list anyway
  • Rejected because: More moving parts than one server needs today; kept as the replacement when grants span several servers.

Tenant records in the server's own namespace, crucible-system (rejected)

  • Pros: One namespace to deploy
  • Cons: Where the loop pod's service account has edit in crucible-system, so pack code could write a record
  • Rejected because: The namespace's write rights are the trust boundary for minting tenants.

ADR-0068: Agents get identity-only tokens from the controller, bound to their run's own gateway

Status: accepted | Date: 2026-10-08

References: ADR-0067, ADR-0036

Context

ADR-0067 gave sandboxed agents access to crucible-kueue with per-run bearer tokens: the controller minted a random token at dispatch, wrote its digest, run id, namespace, role and queue allowlist as a tenant record ConfigMap in a controller-only namespace, and the server watched those records. Building the next consumers (buildit, the agent MCP servers behind the OpenShell provider token grant) and reviewing the design surfaced its limits:

  • Every resource server would need its own record watch and namespace, or share one, and every new server multiplies the deployment surface the records already cost.
  • The record carried authorization (role, queues) decided by the controller from the deploy profile, which the server then replayed. The person behind the run, and what Kubernetes lets that person do, never entered the decision.
  • The token was a static credential in the loop pod's environment, readable by pack command tasks, valid for the whole run.
  • OpenShell already ships a per-sandbox credential flow: the supervisor fetches a SPIFFE JWT-SVID and exchanges it with an OAuth token endpoint (RFC 7523 jwt-bearer client assertion) for an access token it injects on egress, so the agent never holds it.

A SPIFFE ID is only as trustworthy as the pods it can be issued to. A sandbox ID derived from a pod annotation in a namespace pack code can create pods in is a credential pack code can forge, and with a gateway in the loop pod, running as the loop service account, pack code could do exactly that.

Decision

The controller is the OAuth authorization server for sandboxed agents, and its tokens say who is calling, never what they may do.

  • Each run's gateway runs in its own pod. Under the kubernetes driver with [cluster] sandbox_namespace, the controller starts every run's OpenShell gateway (and every turn's) as crucible-openshell-gateway in the sandbox namespace, the only identity with rights there; the loop service account has none. The controller mints a CA per run, a server certificate for the gateway Service's address and client certificates for the loop pod and the supervisors, and keeps no CA key. A NetworkPolicy admits only that run's loop pod and the supervisors its gateway created.
  • The SPIFFE ID names the gateway. Each run's gateway id is its run pod's name; the gateway stamps it as openshell.ai/gateway-id on every supervisor and Sandbox it creates, and driver_config cannot set labels or annotations. The ClusterSPIFFEID, scoped to the sandbox namespace, is spiffe://<td>/openshell/gateway/<gateway id>/sandbox/<sandbox id>.
  • Binding and issue check the gateway. The engine binds each sandbox it creates to its run with the run's pod token; the controller binds only a Sandbox carrying that id and the run's gateway id, and owner-references it to the gateway pod. POST /oauth/token verifies the SVID, resolves the binding, and refuses an SVID whose gateway is not the bound run's.
  • Claims are identity only. sub (the person the run was dispatched for, consumed per dispatch), act.sub (the SVID's SPIFFE ID), run_id, sandbox, workdir, kube_user (the person's Kubernetes username, from an explicit link or an administrator's override), and namespace, the default namespace for the run's workloads. No role, no queues: a resource server decides what the caller may do, crucible-kueue by SubjectAccessReview as kube_user.
  • Tenant records go away. Neither the controller nor crucible-kueue keeps per-run records; the token is the grant.
  • Keys rotate without a flag day. Tokens are ES256 with an RFC 7638 thumbprint kid. The JWKS publishes the current key and, while configured, the previous one, which no longer signs. Tokens live 15 to 30 minutes (CONTROLLER_AGENT_TOKEN_TTL_SECS).
  • SVID trust comes from each cluster's own SPIRE. The controller verifies an SVID against the JWT authorities of the SPIRE that issued it, read through the controller's existing per-cluster API client rather than a separate network route. SPIRE's bundle ConfigMap carries only the X.509 roots, so the keys come from the SPIRE OIDC discovery provider's JWKS reached through the API server's Service proxy.

Consequences

  • A sandboxed agent's calls carry a token it never sees, scoped to one run and one sandbox, minutes long, naming the person and the sandbox; a stolen token is worth one run for at most half an hour.
  • Pack code can no longer mint a sandbox identity: it cannot create pods in the sandbox namespace, and pods its own gateway creates carry its own gateway id, which the issuer will not accept for another run. It can still create sandboxes for its own run with the placement fields the gateway accepts.
  • Authorization moves to the resource servers and to Kubernetes RBAC. A run whose dispatcher has no linked Kubernetes username gets no crucible-kueue scopes.
  • The controller now owns per-run gateway lifecycle in the sandbox namespace (create, adopt, tear down, sweep) and needs RBAC there, rendered for [cluster] sandbox_readers. A cluster the hub only receives pushes from cannot host isolated gateways.
  • The controller's per-cluster client becomes part of the token path: an unreachable spoke means its sandboxes get no tokens until it is reachable again.
  • The OpenShell pin (3441f91) predates v0.1.2; nothing here depends on v0.1.2's sidecar supervisor topology.

Alternatives Considered

Controller-minted identity-only tokens bound to SVIDs from per-run isolated gateways, authorization at the resource server (accepted)

  • Pros: One issuer for every resource server; no records or watches, The token names the person and the sandbox; Kubernetes RBAC decides what they may do, OpenShell's token grant keeps the token out of the sandbox, Pack code cannot forge a sandbox identity
  • Cons: The controller runs a gateway pod per run and turn, Resource servers must verify JWTs and run SubjectAccessReview

Keep tenant records (ADR-0067) and add a record watch per resource server (rejected)

  • Pros: Already built for crucible-kueue
  • Cons: A namespace, RBAC and watch per server, Authorization decided by the profile, not by the person's rights, A static run-long token readable by pack command tasks
  • Rejected because: It scales deployment surface with every server and keeps authorization away from the person's own rights.

Keycloak federated-jwt with a client per run (rejected)

  • Pros: Proven against SPIRE JWT-SVIDs in the spike
  • Cons: Exact-subject matching needs a Keycloak client per sandbox, created, bound and garbage-collected by the controller, A second identity system in the token path
  • Rejected because: Per-run client lifecycle is more moving state than issuing the token directly.

Role and queues in the token, as the tenant records carried (rejected)

  • Pros: The server needs no Kubernetes access to authorize
  • Cons: The controller decides what the person may do from a profile, not from their rights, Two places to change when access changes
  • Rejected because: Authorization belongs to the resource server and Kubernetes RBAC.

One shared gateway per cluster in the sandbox namespace (rejected)

  • Pros: One gateway pod per cluster instead of per run
  • Cons: A gateway admin certificate shared by every run's pack code, so any run can create supervisors with any sandbox id, No per-run gateway id to bind SVIDs to
  • Rejected because: It shares the credential the isolation exists to separate.

Verify SVIDs against a JWKS URL per cluster over routes or services (rejected)

  • Pros: No dependency on the API server
  • Cons: A network path and TLS trust to configure per spoke, Spokes the hub reaches only through their API server need another route
  • Rejected because: The controller already holds an authenticated client for every cluster it dispatches to.

crucible-kueue acts with its own service account and checks each call with a SubjectAccessReview as kube_user (accepted)

  • Pros: The server's own account bounds what any token can reach, whatever the caller's RBAC, The person's RBAC is the ceiling for every call, decided by the API server, The server adds its pull secret, ownership labels and field manager to what it creates
  • Cons: Every call's access checks are modelled by hand in crucible-kueue's access.rs, and a call it models wrong is allowed or refused wrongly, Allowed decisions are cached for 30 seconds, so a removed permission can still allow calls that long, hardware_describe reads nodes with the server's account, an exception to the ceiling kept so agents can pick a flavor and GPU product

Pass the agent's token through to the Kubernetes API server (rejected)

  • Pros: Kubernetes authorizes every request itself; no checks to model
  • Cons: The MCP authorization spec forbids passing a token through to another resource, since its audience names this server, The API server would trust the issuer only with an OIDC authenticator configured on every spoke, A leaked token would be a Kubernetes credential, not a crucible-kueue one
  • Rejected because: It breaks audience binding and turns every agent token into a cluster credential.

Impersonate kube_user for every Kubernetes call (rejected)

  • Pros: Kubernetes authorizes each request as the person, with no checks to model
  • Cons: The server needs impersonate on users and groups, which RBAC cannot scope to a dynamic set of people, so a compromised server acts as anyone, Objects would be created as the person, losing the pull secret, ownership labels and field manager the server adds
  • Rejected because: Unscoped impersonation makes the server's account worth every user's rights.

ADR-0069: A launch sets agent settings per task and the checkout ref, within bounds the manifest declares

Status: proposed | Date: 2026-10-27

References: RFC-0001:C-MANIFEST, RFC-0001:C-WORKFLOW, RFC-0001:C-WIRE, RFC-0001:C-CAPABILITY-DISCLOSURE, RFC-0002:C-PLAYBOOK-PARAMS, RFC-0002:C-PLAYBOOK-RERUN, ADR-0054, ADR-0065

Context

Two launch-time choices have no home. A playbook with several agent tasks needs a different model or reasoning effort per task, chosen when it is launched rather than when the pack is written. And a pack needs to run against a branch under test rather than the branch its manifest names. A pack can pin model, harness and effort on each agent(), [agent] sets them for the whole run, a launch can replace [agent]'s harness and model for the whole run, and [repo].ref is fixed in the manifest. Nothing lets a launch change one task or the checkout.

An engine prototype of the chosen direction established the constraints below; the first three ruled out the obvious designs.

  • A string parameter a launcher supplied cannot reach any constructor argument but a prompt. The compiler marks it as outside text and refuses it elsewhere (ADR-0054), so agent(model = param("review_model")) and workflow(ref = param("sha")) compile only while the parameter takes its default. The params route for this need does not exist today.
  • crucible.toml is read without parameters in several places: the controller stores [agent] requirements at import, the exposure is computed from it, and the LSP and crucible check read it. Its fields are typed (gpus is an integer), and it is the reviewed surface: commands, credentials, egress and images.
  • A session() is one conversation under one agent config. Its defaults fill its tasks' unset knobs at compile time and a conflicting pin is a compile error.
  • Defaults already exist at two levels: [agent] for the run and session() for a group of tasks. A third, workflow-wide default would add nothing a launch-wide value does not.
  • A rerun refuses a reused task whose compiled definition changed (RFC-0002:C-PLAYBOOK-RERUN), and the run identity hashes a compiled graph and the workspace's starting commit (RFC-0001:C-WIRE). Neither sees the run-level knobs on the engine's argument set. A resume and a rerun restore the commit the run started from, not the ref that produced it.
  • Every launch path stores and replays a parameter map: launch rows, standing launches, schedules, webhook mappings, portal forms, rerun and resume. The controller validates that map against a JSON Schema with additionalProperties: false, and quotes each value into the work pod's command line.
  • An injected inference binding (ADR-0065) replaces the run-level model, but a task's own pin is applied after it and wins.
  • A harness or effort a task pinned was parsed only when its turn started, so a typo failed the run after it had spent money.
  • The clone checked out [repo].ref with git checkout <name> and ignored a failure, so a misspelled ref ran on the default branch. That form also cannot resolve a fully qualified ref (refs/heads/x, what a push webhook sends) or a pull request ref.
  • A branch is code nobody reviewed. Command and evaluate tasks run outside the sandbox with their executor's reach (RFC-0001:C-CAPABILITY-DISCLOSURE), so whatever they call from the workspace comes from the branch. A pull request ref is code from a fork, and a forge may serve a fork's commit by id through the parent repository.
  • A pack whose setup_cmd builds the workspace never clones, and a manifest that lives inside its own workspace is read from that workspace's commit.

Decision

Addressing. A launch sets a value in the same name=value map as declared parameters: <task>.<setting> for one agent task, *.<setting> for every agent task, repo.ref for the checkout. The agent settings are harness, model and effort. A declared parameter name may not contain a dot, so the two kinds of name cannot collide. A task name may, so an agent address splits at its last dot; ref is only a repo setting, so repo.ref cannot be read as a task named repo.

Precedence. The more specific value wins, and at equal specificity the launch wins:

[agent] < run-wide launch flags < *.<setting> < the task's own value < <task>.<setting>

*.<setting> fills each agent task that leaves the setting unset, and <task>.<setting> replaces what that task sets. Both are written onto the compiled tasks, not onto the run's knobs, so the rendered graph, the rerun definition digest and the run identity cover them without new rules. A launch that sets a run-wide harness or model both as a flag and as *.<setting> is refused. repo.ref replaces [repo].ref for the run.

Bounds. The manifest declares what a launch may change, as static TOML readable without compiling the workflow, the same shape as an [outputs] scope bound to a parameter. [agent.overrides] names the settings a launch may set (allow) and, optionally, the models it may name (models). [repo.overrides] names the refs it may check out (refs), as globs over branch and tag short names where * stops at / and ** crosses it. A pull request ref matches only a pattern that spells refs/pull/, so fork code is never admitted by a broad pattern. Without a table a launch changes nothing. A composite manifest refuses [agent.overrides], since a composite takes no launch values, and [repo.overrides] is refused beside a setup_cmd or without a repo to clone.

Checks. Every value is read and checked before anything is cloned or dispatched: the setting is known and allowed, the value parses (harness and effort case-insensitively, as a turn reads them; a model is one argument's worth of characters and is listed when models is set; a ref follows git's ref grammar as libgit2 checks it and does not start with -), and an agent address names a task that exists, runs an agent and is not in a session. A task-addressed value is refused on a session's task because it would split the session's config. A ref is refused when the manifest lives inside the workspace, since the checked-out ref would supply its own manifest. The harness and effort parse also runs when a plan is validated, so a pack's own typo fails at compile time for a compiled graph, an inline one, and a plan file alike; a written [repo].ref is held to the same ref grammar.

Checkout. The clone fetches the ref by name from the origin and checks it out detached, so a short name, a fully qualified ref, a tag and a pull request ref resolve alike. A commit the clone already holds is checked out without a fetch. A ref that cannot be fetched or checked out fails the run, and the clone is removed so no later run reuses it at the default branch. A launched commit is kept only when a branch or tag the patterns admit holds it in the fresh clone; otherwise the clone is removed and the run refused. A fresh run with a launched ref needs a fresh workspace; a resume or rerun keeps the commit its run recorded.

Scope. This decision covers agent settings on playbook and custom graphs and the checkout of a single-repo pack. A sandbox, a prompt, a command and an image are never launch settings.

Consequences

  • One launch can run each agent task on its own model or effort, or the whole pack against a branch under test, within what the pack allows, and a misaddressed or disallowed value fails before anything is spent.
  • Launch values ride the existing parameter map, so launch rows, schedules, standing launches, webhook mappings, rerun and resume store them unchanged, and a push webhook can map repo.ref from the pushed ref or commit. The controller's launch schema still refuses dotted names; it needs generated properties per agent task, read from the graph it compiles at registration, and a repo.ref property when the pack declares patterns. A graph whose task names depend on parameter values cannot list those tasks ahead of a launch, and the controller's rerun compile has to split launch values from declared parameters before binding, as the engine does.
  • *.model on a task beats an injected inference binding, where the run-wide flag loses to it. That matches how a task's own pin already behaves and differs from the flag. Which one is right depends on whether a binding is a model choice or an endpoint that can serve one model; until that is settled, models is how a pack keeps launch values to models its deployment serves.
  • A session can only be changed as a whole: *.<setting> reaches all of its tasks alike, and no address reaches one of them. Addressing a session by name is a possible follow-up.
  • [repo.overrides] widens what runs on the executor to every branch its patterns match. A pack that runs repository scripts in command tasks should name narrow patterns. A pack that admits refs/pull/ runs code from forks there. A commit pin is admitted only through the branches and tags a clone fetches, so a commit that only a pull request ref holds is refused even under a refs/pull/ pattern; launch the pull request ref instead.
  • A misspelled [repo].ref, which used to run on the default branch, now fails the run. A pack whose written ref breaks git's grammar fails to load.
  • A local fresh run with a launched ref refuses an existing workspace instead of reusing a checkout of some other ref; remove the workspace to launch again.
  • The checks add amendments to RFC-0001:C-MANIFEST ([agent.overrides], [repo.overrides], the ref grammar and the checkout), to RFC-0002:C-PLAYBOOK-PARAMS (addressed names, dots reserved) and to RFC-0001:C-WORKFLOW (harness and effort refused at validation). A pack with a misspelled harness or effort that has never run now fails to compile instead of failing its first turn.
  • The plan IR still carries harness and effort as strings, parsed in two places. Typing them is a follow-up.
  • Not covered: sandbox resources (GPUs) need per-task sandbox sizing in the IR and the sandbox driver. Composite components take no launch ref. The scored lane's candidate turn is not an agent task and takes no address, and deploy render takes parameters only for playbooks.

Alternatives Considered

Address launch values per task and per repo in the launch parameter map, bounded by [agent.overrides] and [repo.overrides], most specific value wins (accepted)

  • Pros: Every launch path already stores and replays the parameter map., Values are checked against a closed vocabulary or git's ref grammar before dispatch, so outside text needs no exemption from the prompt-only rule., Agent settings are written onto compiled tasks, and a ref decides the starting commit, so the rerun digest, the run identity and the rendered graph cover both., The manifest stays static and reviewed; the bounds are readable without compiling.
  • Cons: The controller's launch schema has to generate a property per agent task and setting, and one for the ref., Dotted names become reserved in params blocks.

Template allowlisted crucible.toml fields with parameters (rejected)

  • Pros: One place for every value a launch changes, including the ref and GPU counts.
  • Cons: Every reader that parses the manifest without parameters sees an unresolved value, and a typed field cannot hold one., Run-wide fields only; it cannot give one task its own model., Moves part of the reviewed surface to launch time.
  • Rejected because: The manifest is read before parameters exist, and the agent need is per task.

Declare a parameter per task and setting and pass it to agent() and workflow() (rejected)

  • Pros: Uses the existing params block, its schema and its launch form.
  • Cons: A supplied string is refused outside a prompt, so it works only for defaults., Relaxing that per argument puts value checks in each constructor., One parameter per task and setting; the launch form grows with the graph.
  • Rejected because: It does not work for supplied values, and making it work means exempting outside text argument by argument.

A workflow-wide defaults argument, after Airflow's default_args (rejected)

  • Pros: Familiar to Airflow users.
  • Cons: [agent] and session() already provide run-wide and group defaults., Adds a layer that *. would have to sit above.
  • Rejected because: Redundant with the defaults that exist.

A separate settings field on the launch request (rejected)

  • Pros: Settings and parameters stay apart on the wire.
  • Cons: Launch rows, standing launches, schedules, webhook mappings, portal forms, rerun and resume would each need the new field.
  • Rejected because: The parameter map already reaches every launch path.

Apply *. to the run's knobs instead of the tasks (rejected)

  • Pros: Reaches the scored lane's candidate turn, which is not an agent task.
  • Cons: The rerun digest and the run identity do not see run-level knobs, so a rerun would reuse tasks that ran on a different model.
  • Rejected because: A change the rerun and identity cannot see is a change they cannot refuse.

Let a launch check out any ref of the configured repository (rejected)

  • Pros: No patterns to maintain; the repository URL is already reviewed.
  • Cons: Any branch, including one nobody reviewed, runs code on the executor through command tasks., Pull request refs and commit ids reach fork code.
  • Rejected because: The repository URL bounds where code comes from, not which code; the pack has to bound which refs.

Check out refs with a shallow clone of the named branch (rejected)

  • Pros: Smaller clones.
  • Cons: clone --branch takes a branch or tag, not a commit or a pull request ref., A commit pin cannot be checked against the branches that hold it without their history.
  • Rejected because: A full clone plus an explicit fetch resolves every ref form and keeps the history the pin check reads.

ADR-0070: A dynamic choice reads its options from a dependency field, bounded and recorded

Status: proposed | Date: 2026-10-08

References: RFC-0001:C-ROUTE, RFC-0001:C-ROUTE-EACH, RFC-0001:C-ROUTE-OPTIONS, ADR-0062

Context

A choice question's options are written in the playbook (RFC-0001:C-ROUTE), so a route can only pick among labels its author knew when the pack was written. Some decisions pick among data instead.

The motivating case is a CVE triage playbook. A flaw names its affected package in free text ("Go net/http"), and the shipped image's SBOM lists the packages actually in it. Deciding whether the flaw touches the image is matching that text to one SBOM entry, or to none. The entries differ per image and per ticket, so the options cannot be authored, and with a route mapped over tickets (ADR-0062) each element needs its own list.

What exists today:

  • A mapped route decides once per element, and keyed narrows an unmapped dependency's object field to the element's entry (RFC-0001:C-ROUTE-EACH).
  • Both decision APIs take a choice's options with each request: System One as a map of label to criterion, OpenAI Decisions as a list of value and description. Nothing in either wire shape needs the options to be known before the request is built.
  • Labels are identifiers, the engine derives the answer from the returned probabilities, and when, drop, otherwise, and label coverage are checked against declared labels at compile time.
  • A route's output is its decision record, folded on resume, so an element is asked once per run.

Decision

Let choice(options = producer.field) name a dependency's field in place of the option list. The normative rules are RFC-0001:C-ROUTE-OPTIONS.

  • Where the list comes from. The field is read the way keyed reads one: from an unmapped dependency, as the list itself for an unmapped route and as the element's entry of an object keyed by element for a mapped route. The compiler checks the reference against the producer's declared emits exactly as it checks keyed.
  • What an entry is. A string label, or {value, description}. Labels stay identifiers, so the answer record, the probability map, and when keep one label type; a producer whose data is not identifier-shaped puts the raw text in the description and a short key in value, which is also what the model should see.
  • Bounds and duplicates. At most 64 entries, checked before any entry is read. Duplicated labels collapse to the first entry. Fewer than two options after that, a malformed entry, or the reserved "uncertain" fails that element's decision before any request, and other elements are unaffected.
  • Codecs. A compiled plan's question and the question the broker asks are separate types, and only the first can be dynamic. The engine resolves each dynamic choice into an ordinary choice before calling the broker, so the broker never sees one and both codecs send it as they send any declared choice.
  • Record. The answer carries the resolved options beside the label and distribution, so a reader sees what the model chose among without re-running the producer.
  • Gating. Labels are unknown at compile time, so a when may list only "uncertain", and otherwise stands for every resolved option plus "uncertain" when nothing else lists it. The options count as one label for coverage. A downstream task reads which option was picked from the route's output.
  • Deciders. Only a model-decided route may ask a dynamic choice. An output-decided route reads a label its source already computed; matching it against a list the same source could check itself adds a rule and no decision.

For the triage playbook, sketched (illustrative; the clause is the contract):

prepare = command(name = "prepare", run = "...", emits = {"members": "list", "facts": "object", "packages": "object"})
match = route(name = "match", depends_on = [prepare], over = prepare.members, max_fanout = 120,
              keyed = [prepare.facts], min_confidence = 0.7,
              questions = {"package": choice(ask = "Which shipped package does the flaw affect?",
                                             options = prepare.packages)})
analyze = agent(name = "analyze", depends_on = [prepare, match], over = prepare.members, max_fanout = 120,
                when = match.package, otherwise = True, ...)
review = agent(name = "review", depends_on = [prepare, match], over = prepare.members, max_fanout = 120,
               when = match.package, answers = "uncertain", ...)

prepare emits, per ticket, the SBOM candidates as {"value": "p3", "description": "golang.org/x/net 0.17.0"} with a {"value": "none", "description": "No shipped package matches"} entry of its own, so "none" is always an option and the analyze turn reads which one the route picked.

Consequences

  • A route can decide among data the pack does not know in advance, per element, with the same calibration, refusal handling, and resume behaviour as a declared choice.
  • A producer has to emit identifier labels. Raw package names such as golang.org/x/net are not identifiers, so the producer keys them and puts the name in the description. The engine does not invent labels on its behalf.
  • The 64-entry bound is the engine's, not the author's. A producer with a longer list has to narrow it first, which is also where match quality is won; truncating would silently drop the right answer.
  • An always-present option such as "none" is the producer's job. The engine adds no options, so a list the producer forgot to extend is a list without it.
  • when cannot branch on a particular option. Splitting work by which package matched means reading the label in the downstream task, or a second, declared choice over the dynamic answer.
  • Records grow by the option list for each dynamic answer.
  • The plan admitted event's when string and the compiled TOML gain the any-option flag; a reader that predates it sees only the listed labels.

Alternatives Considered

A field reference resolved per decision, identifier labels, bounded, recorded with the answer (accepted)

  • Pros: Reuses keyed's reference rules, the mapped-route instance key, the codecs, and the answer resolution unchanged., Every rule about answers keeps one label type., The record is self-contained for audit.
  • Cons: Producers must shape labels as identifiers., when cannot name an option.

Accept any string as an option value and record it verbatim (rejected)

  • Pros: A producer can pass SBOM names through unchanged.
  • Cons: Answers, probability maps, and when would need a second label type, or labels would stop being identifiers everywhere., Arbitrary text as a map key in the record invites collisions in case and whitespace that the model and the reader treat differently.
  • Rejected because: Widens a type every route rule depends on to save the producer one mapping step.

Merge declared options with the dynamic list, so a fixed option such as none is always present and when may name it

  • Pros: The author guarantees none exists, and can gate on it.
  • Cons: A label may come from either side, so collisions between declared and emitted labels need their own rule., Coverage becomes partly static and partly any-option.
  • Rejected because: Not needed for the first user, whose producer can emit none itself; it can be added later without changing this record's shape.

Truncate an over-long list to the bound (rejected)

  • Pros: A run never fails on list length.
  • Cons: The dropped tail may hold the right answer, and the model cannot say so.
  • Rejected because: A silent wrong answer is worse than a failed element the author can fix.

Do the matching in the producer and gate with an output-decided route (rejected)

  • Pros: No new question form.
  • Cons: Free-text package names do not match SBOM entries by string rules; that judgment is what the decision model is for., The match would carry no distribution or confidence.
  • Rejected because: Moves the hard part into a script that cannot do it.

ADR-0071: A pack edition gates contract changes, and edition 2 gives host commands only declared env

Status: proposed | Date: 2026-11-03

References: RFC-0001:C-COMMANDS, RFC-0001:C-CAPABILITY-DISCLOSURE, RFC-0004:C-CONTRACT-VERSION, ADR-0058

Context

Until #294, measure_cmd, the [world] hooks, and workflow command and evaluate tasks ran with the engine's whole environment: kubeconfig, registry authfile, broker and pod tokens, provider keys, projected secrets. Each of them can be a script the agent was able to edit, so anything it ran could read all of that. #294 narrowed them to PATH, HOME, LANG, TMPDIR, the variables the engine sets for each command, and what the command's table declares in [judge].env, [world].env, or [workflow].env (RFC-0001:C-COMMANDS), and disclosed each declared variable as reach (RFC-0001:C-CAPABILITY-DISCLOSURE).

That narrowing changes what an unchanged pack's commands receive. Packs in use have host commands that read variables they never declared; they could not have, since the env lists did not exist. On the narrowed engine those commands lose the values: a shell default takes over, or the command fails. Nothing in the pack changed and nobody re-approved it.

Constraints:

  • The registry re-derives a stored pack's schema and exposure whenever the linked engine changes, without asking anyone (ADR-0058 traces this). An engine release that changes what a pack's commands receive changes an approved pack's behaviour while its source digest stays the same.
  • ADR-0058 already proposes a pack edition as the opt-in for a contract change, for declared input files. It places the key in [workflow] and maps it one-to-one to the plan IR version. Host env is not a workflow property: a scored pack with no [workflow] table still runs measure_cmd and its [world] hooks.
  • Manifest and CompositeManifest deny unknown fields, so an engine that predates a new top-level key refuses it at parse rather than ignoring it.
  • An edition-1 pack that keeps inheriting still holds that reach. The disclosure has to say so, or a reviewer reads an unlisted variable as one the pack cannot reach.

Decision

Gate the narrowing on a pack edition, declared at the manifest's top level.

  • The key. edition is a top-level key of crucible.toml, in single and composite manifests alike. Absent is edition 1. An engine supports a fixed set of editions and refuses any other at parse, naming the ones it supports. An engine runs every edition it supports with that edition's semantics, so an unchanged pack behaves the same across engine upgrades, and a pack changes behaviour only when its author raises the edition.
  • Editions are cumulative. Each edition is the previous one plus one named contract change. A pack that wants a later change takes every earlier one with it.
  • Edition 2 is declared host env. Host commands get the base, the engine's own variables for them, and what their table declares, as RFC-0001:C-COMMANDS states it. The disclosure names each declared variable by table.
  • Edition 1 inherits. Host commands keep the engine's environment, plus the variables the engine sets for them. An env list in an edition-1 manifest fails the parse naming the table and edition 2, because it would read as a narrowing that never happens. crucible check and every run start warn that the pack inherits and should move to edition 2, and the disclosure states, per table whose commands run, that they inherit the engine's whole environment.
  • Where the edition lives. The manifest owns it. A workflow's compiled form carries it from the manifest, as it carries the source it was compiled from, so a site that builds a plan from the workflow alone can read it.
  • Amends ADR-0058. Its declared inputs become edition 3, its key moves from [workflow] to the top level, and editions 1 and 2 compile to plan IR v1, edition 3 to v2.

The edition is the manifest's rather than the workflow's because the change it gates reaches [judge] and [world]. It is cumulative rather than a set of feature flags because each edition is one combination the engine is tested in.

Consequences

  • Every pack that ran before keeps running as it did. It is warned at check and at every run start, and its disclosure now states the inherited reach, so a reviewer sees it.
  • The exposure of every edition-1 pack that runs host commands gains host-env entries. The registry's re-derivation updates the stored exposure on the engine bump, and the digest an approval accepted moves with it.
  • Moving a pack to edition 2 means declaring every variable its host commands read, across [judge], [world], and [workflow], in one change. A command that reads an undeclared variable finds it unset at run time; nothing static can see what a script reads.
  • An engine that predates editions refuses an edition-2 manifest at parse, because the key is unknown to it; it never runs the pack with inherited env. RFC-0004:C-CONTRACT-VERSION already keeps dispatch images in step with the linked engine.
  • The engine carries the inheriting path until no pack uses edition 1. Retiring edition 1 is a later decision.
  • Because editions are cumulative, a pack cannot take ADR-0058's declared inputs without also declaring its host env.
  • setup_cmd and the --agent-cmd stand-in run with the engine's environment in every edition, as before.
  • A plan file run without a manifest has no edition and runs as edition 1.

Alternatives Considered

A top-level manifest edition, cumulative, where edition 2 is declared host env and edition 1 keeps inheriting (accepted)

  • Pros: Every pack that runs today keeps running on the new engine, with no source change and no re-approval., One key covers [judge], [world], and [workflow], so a scored pack with no workflow is governed too., An older engine refuses the key at parse instead of running the pack with inherited env., The disclosure and the warnings make the remaining inherited reach visible.
  • Cons: The engine carries two host-env paths until edition 1 is retired., Edition-1 packs keep their reach until their authors move them., Cumulative editions tie unrelated changes together: a later edition requires declared env.

Narrow every pack in one engine release, with no edition (rejected)

  • Pros: One host-env path, and no pack keeps the engine's credentials.
  • Cons: Live packs whose commands read undeclared variables break on the upgrade., Approved packs change behaviour with no source change and no re-approval, and no stored digest says so.
  • Rejected because: It breaks running packs in exchange for not carrying the inheriting path.

Opt in per table by declaring an env list: a table with env gets the allowlist, one without inherits (rejected)

  • Pros: A pack ports one table at a time and needs no new key.
  • Cons: Absent and empty mean different things: a table whose commands need nothing has to write env = [] to stop inheriting., Inheriting stays the silent default for every table an author forgets, with nothing that says to move., Which environment a command gets becomes a per-table fact a reviewer has to read off every table.
  • Rejected because: It keeps the credential-reading default for every table nobody touched, and makes the narrowing a property of each table instead of the pack.

Place the edition in [workflow], as ADR-0058 proposed (rejected)

  • Pros: The key sits beside the workflow fields whose compiled form it changes.
  • Cons: measure_cmd and the [world] hooks run in packs that have no [workflow] table, so the key cannot reach them., A pack's edition would differ between its workflow and its judge.
  • Rejected because: Host env reaches [judge] and [world]; an edition that only a workflow can state cannot gate it.

Independent per-change switches in the manifest, such as inherit_env = false per table (rejected)

  • Pros: A pack takes exactly the changes it wants.
  • Cons: The engine has to run and test every combination of switches, and the combinations grow with each change., The switches accumulate in manifests long after their defaults have flipped.
  • Rejected because: An edition names one tested combination; switches name a product of them.

An operator setting in the engine's run configuration that turns the narrowing on per deployment (rejected)

  • Pros: No pack changes; an operator narrows a whole deployment at once.
  • Cons: The same pack behaves differently on two deployments., The disclosure is computed from the pack alone and could not state which environment a command gets.
  • Rejected because: What a pack's commands can reach has to be a property of the pack that its disclosure can state.