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 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.