Crucible implementation contract (frozen interface)
This is the spec for the interface between the engine (crucible) and a
domain. The engine implements it, a domain satisfies it, and a minimal fake domain
(examples/counter/) tests it end-to-end with no EPP and no cluster.
Concept: What crucible is. 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.
[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_cmd = "..." # command backend only (§6)
[agent.env] # injected into the agent process (creds, Vertex, etc.)
ANTHROPIC_VERTEX_PROJECT_ID = "my-gcp-project"
[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"
[judge.selftest] # optional (ADR-0014 S1). 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.
[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:
[repo](url xor path),[judge].measure_cmd,[judge].direction. [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 bothgood_cmdandbad_cmd(a self-test that only stages one side isn't a control);runsmust be>= 1.[search], if present withwide > 0, requiresapproaches.len() >= wide(hard error, diversity is engineered, not auto-generated) andpolicy_kin1..=wide.- 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 initscaffolds[repo] path = "."), the engine parses it from the workspace's pristine base commit, not the current working tree (so an in-flight agent edit tocrucible.tomlcan'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.
1.1 The gate self-test (crucible check, ADR-0014 S1)
[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:
- snapshot the pristine workspace,
- restore to pristine, stage
good_cmd, measurerunstimes through the domain's ownJudge, restore to pristine again, - same for
bad_cmd, - pass iff both controls' readings are all
validandgood's mean score is strictly better thanbad'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/effortare 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 stringsession = "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 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.
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. Hermes fails closed for a persistent binding until it has an equivalent
continuation store; it never 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 with no research lifecycle at all—for example, a creative studio that 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 pretend to satisfy autoresearch just because 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(...), anddecide(...)create capability-owned engine tasks.decide(measurement = score)selects its measurement. Scored lanes only.agent(...)creates an agent task.isolated = Truegives 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 thesession =value onagent()andpropose().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.load(path, name, ...)pulls symbols from another.starfile under the pack, resolved by the same policy asprompt_fileand refused for absolute paths,.., symlinks, non-files, and cycles. Loaded modules run before the root against the same globals and the same compile state: theirprompt_file()calls resolve against the pack root and charge the same byte budget, theirsession()declarations precede every root reference, and a task they construct at module level must still appear inworkflow(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, intasksand independs_onalike, 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:
| kwarg | values | what it decides |
|---|---|---|
required | True (default), False | whether 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. |
isolated | False (default), True | whether 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 name | a 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-size ceilings. The compiler exposes no filesystem API except prompt_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.
| kind | default count | default target |
|---|---|---|
draft-pr | 2 | [publish].pr_repo, else $AUTORESEARCH_PR_REPO |
tracker-comment | 2 | the run's parameterizing item ($CRUCIBLE_ITEM) |
chat-message | 8 | the engine's operator channel |
image-push | 100 | $FORGE_REGISTRY |
deploy | 100 | $FORGE_DEPLOY_NAMESPACE/$FORGE_DEPLOY_NAME |
workflow-dispatch | 20 | the single [build.*.github].repo, when unambiguous |
gpu-capture | 100 | addresses 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 two kinds the engine writes itself rather than through a broker tool — the draft PRs
publish-on-keep opens and the workflow_dispatch a github-actions build fires — are mediated in
the engine 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)
| Thing | Resolves to |
|---|---|
config: method_prompt, goal_file, toolbox_dir | manifest-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 = the engine's env +
[agent].env(where relevant) + the injected variables below.
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 whosescoreis effectively boolean; on an exactscoretie, a strictly bettertiebreakstill 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. EPPcache_hit_rate; the test gate'stotal).
- exit code: nonzero ⇒ the reading is forced
valid:falseregardless 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-0020.
| Mode | When it applies | What apply_cmd does | Cost |
|---|---|---|---|
| no artifact | the gate compiles + runs the workspace in place | nothing (omit apply_cmd) | seconds |
| no rebuild | the agent changes config of a live rig | push the config, wait for rollout | a rollout |
| derive-layer | the changed sources are interpreted (Python) | append them onto a pinned base as a real OCI layer | ~8s |
| image | the changed sources are compiled (Go, Rust, C++) | a real container build; the compile happens here | minutes |
Rules that are easy to get wrong, and that crucible check should catch before you spend a turn:
derive-layerrequires 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-layercannot carry compiled sources. Appending a.gofile 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
imagemode 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. Anapply_cmdthat collapses "did not compile" into "scored badly" throws that away. - 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 = trueinject (§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 envCRUCIBLE_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 enginegit inits the workspace and commits the result as the baseline when setup left no repo behind, so asetup_cmdonly has to produce the tree, never the commit. Asetup_cmdthat 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.
solvedimplieskeep. A win is the whole point, so a candidate the measure command declaressolvedis kept (and terminates the loop) even if its score doesn't strictly beat best. This is load-bearing for any domain whose win lands at an equal score: EPP's test gate wins with a green suite (0 failures == the baseline's 0) plus a new regression test. Without|| solvedthat win would be discarded and the loop would never finish.solvednever rescues an invalid reading.- The first valid reading sets the baseline (
best_score, andbaseline_total=detail.totalif 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_TOTALand emitssolved.
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 → runrestore_cmdwithCRUCIBLE_TOKEN=<domain-token>. - The engine exposes
last_commit_sha()(the git half) forkept_shas/publish; the domain half is never inspected.
- keep: commit (git half) → run
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: directclaude --output-format stream-jsonwith[agent].env(real Claude/Vertex turn).openshell: sandboxed pod turn driven by the in-Rust OpenShell driver (backend = "openshell",sandbox_image).command: run[agent].agent_cmdviash -cin the workspace as the proposal. A deterministic, free proposer (no LLM). This is a real transport, not a mock: it makes the minimal example a fast, deterministic e2e (e.g.agent_cmd = "./bump.nu"increments a counter). Use it to test the engine's loop/protocol without burning tokens.
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 intosandbox_image.
The manifest [agent] block (backend, sandbox_image, env, relay, openshell) selects and
configures the backend; flipping local↔openshell is config, not code. What the manifest
cannot do is build the sandbox image or inject creds for you, that's the inherent cost of
sandboxing, called out here so it's a known rule, not a surprise. Reversibility commands
(snapshot/restore) run engine-side, so they reach the live system directly regardless of
backend; only the agent is boxed.
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-0019 P2). 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). Because both are derived from the same resolved
URL, the allowlist entry and the address the sandbox contacts cannot disagree. The broker
endpoint is appended regardless of inherit_defaults, because the broker is engine plumbing
the domain opted into, not a built-in the domain can subtract. A broker-less opt-out with both
lists empty is still a legal total air-gap: nothing resolves, and no binary may open a socket.
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.
Additive event kinds beyond the compat set include:
identity: the run'sRunIdentity(below), emitted once at setup and again on--resume(the freshly recomputed identity). A mismatch against the original run's identity is a hard-warningnoteevent, never an abort.shutdown:{ outcome, reason }, emitted exactly once, as the last line of every run (afterfinished/summary).outcomeis one offinished/solved/budget/stopped/escalated/stalled/error. Session-log consumers key a run's terminal state off this line; a dead stream with noshutdownline means the pod likely died mid-run, not a clean exit.--resumeconsumes this invariant, not just documents it: a resumed run classifies the log tail (seerecoverybelow) and a trailingshutdownis the "exited on purpose" signal. In a resumed (appended) log, only the trailingshutdowncounts; 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 distinguishstartedfromresumed. 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.modeisblock(the loop parks idle) orcontinue(it keeps iterating in the frozen regime). Bracket invariant: everyapproval_waitis closed by anapproval_resolvedexcept 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 operatorapprovestill resolves it.approval_resolved:{ outcome, reason }withoutcomeone ofgranted/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--resumeright after the resume note: how the resumed process classified its predecessor's end.classis one ofclean_exit/died_in_baseline/died_in_wide_round/died_mid_turn/died_deciding/died_in_plan_task/died_awaiting_approval/died_between_iterations;iteris the iteration the interruption touched (0 when not iteration-scoped);detailis 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 }—inputis the command token and the payload is flattened alongside it ({"input":"rescope","regime":"c=48"}).settled:{ key, outcome, ts, note }withoutcomeone ofapplied/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)
- a
crucible.toml, - a
measurecommand emitting{valid, score, solved?}(any language), - optionally
apply/snapshot/restorecommands, - a method prompt + goal,
- 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 minimalcrucible.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, runsmeasure_cmdonce 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: ameasure_cmdtoken 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-0014 S0): pipeline over a hand-written domain pack. ingest the goal (from--issue, fetched natively from the GitHub REST API (honorsGITHUB_TOKEN/GH_TOKENandGITHUB_API_URL), or--goal-file, or the pack's own[agent].goal/goal_file), then validate (crucible check, as a library call), then freeze (writesSCOPE.mdin the pack dir with the goal, the check outcome, and the pack'sRunIdentitydigest). Stops at the first failing stage.S2(propose)/S3(preflight)/S4(approval) are listed inSCOPE.mdas pending, see ADR 0014. 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 theapp.kubernetes.io/managed-by=cruciblelabel every rendered loop pod carries.ITERships as-(reserved, seecrucible/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).--playbookrenders the second mode: the pod runscrucible plan runover 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-costand--max-time, and needs neither a[deploy]block nor[agent].sandbox_image(both describe a deployment a playbook never performs).--packis orthogonal: it controls delivery,--playbookcontrols 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 (--prrepeatable: 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.--oncefetches and exits instead of polling.