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

RFC-0001: Crucible implementation contract

Version: 0.2.0 | Status: normative | Phase: spec Owners: @Will Eaton


1. Summary


2. Specification

[RFC-0001:C-MANIFEST] Manifest schema and validation (Normative)

The engine must read exactly one manifest per run. Default path is ./crucible.toml, overridable via --manifest.

The manifest directory (dirname of the manifest file) must anchor all config-relative paths.

Required fields: [repo], when present, must contain exactly one of url or path. [repo] may be omitted only by a manifest whose [workflow].type is playbook; every other manifest must carry it. When [judge] is present, [judge].measure_cmd and [judge].direction are required. When [judge] is absent, the run is a task lane.

[workspace].inject entries take two forms. A table names src (manifest-relative), dst (workspace-relative), and frozen (default true). A string is shorthand for a table whose dst equals its src and whose frozen is true. A string that names a directory or contains a glob metacharacter must expand to every regular file it matches under the manifest directory, in sorted path order, each becoming its own frozen entry with dst equal to its manifest-relative path. Expansion must not escape the manifest directory, and a string that matches no file must be rejected as a manifest error naming the string.

[world] with no commands must produce GitWorld. Any command given must produce CommandWorld, which layers domain commands on top of git memory.

[judge.selftest], if present, must require both good_cmd and bad_cmd; runs must be >= 1.

[search], if present with wide > 0, must require approaches.len() >= wide and policy_k in 1..=wide.

Unknown manifest keys must be rejected as errors.

Frozen loading: when the manifest lives inside the workspace it targets, the engine must parse it from the workspace's pristine base commit, not the current working tree. Before any base commit exists (the very first run), the engine must hard-warn and trust the working tree for that run only.

Since: v0.1.0

[RFC-0001:C-SELFTEST] Gate self-test protocol (Normative)

[judge.selftest] declares two controls the gate must tell apart before it is trusted. crucible check must run it pre-loop, never inside a loop iteration.

The protocol must:

  1. Snapshot the pristine workspace.
  2. Restore to pristine, stage good_cmd, measure runs times through the domain's Judge, restore to pristine again.
  3. Same for bad_cmd.
  4. Pass if and only if both controls' readings are all valid and good's mean score is strictly better than bad's per [judge].direction.

The workspace must be restored to pristine on every exit path (pass, fail, or error).

A manifest with no [judge.selftest] must not be an error; crucible check must warn instead.

Since: v0.1.0

[RFC-0001:C-SEARCH] Wide-round search protocol (Normative)

[search].wide > 0 (or --wide N on the CLI, which overrides the manifest) must fan out N independent PROPOSE turns in per-candidate git worktrees before the deep loop starts, one turn per approaches entry biased into its prompt.

Each candidate's diff must be applied (cherry-picked) into the shared main workspace and measured serially (measurement must never run concurrently; only proposal does).

The scored set must be ranked by [search].policy (v1: top-k); the policy_k winners must seed the deep loop.

Session rows from the wide round must carry an additive phase: "wide" field so a consumer can distinguish a wide-round row from a deep-loop row without a wire-shape change. The field must be skip_serializing_if None so a deep-only run's wire bytes are unchanged.

Since: v0.1.0

[RFC-0001:C-WORKFLOW] Scope-authored workflow DSL (Normative)

A scoped pack may include workflow.star beside crucible.toml. It is authoring syntax; scope must compile it to [[workflow.task]] manifest IR before validation and again before freeze. The generated TOML is the runtime authority.

The DSL must accept: assignments, scalar values, lists, dictionaries, list concatenation, conditional expressions, comprehensions, iteration, user-defined functions, and direct calls to the constructors its lane defines. Every lane has agent, command, evaluate, skill, session, prompt_file, param, and workflow. A skill task is an agent task whose prompt is assembled rather than written: the instructions come from a SKILL.md the pack ships under a directory the task names, and the arguments the invocation supplies are rendered after them. It is a naming and reuse construct only, and must not widen what a task may reach. The scored lanes add propose, apply, measure, grade, decide, top_k, and default_autoresearch. A constructor outside the declared lane must not be in scope: naming one must be an unknown-name error at its own location rather than a validation failure after the graph compiles, and a did-you-mean must never offer one. The lane must therefore be readable from the source before the source is evaluated. Starlark's own pure builtins may be reached; a builtin that reaches the filesystem, a process, the network, the clock, or a source of randomness must be absent. The DSL must not provide mutation of frozen values, filesystem access other than prompt_file() and load(), processes, network, time, or randomness.

An author-supplied source is untrusted input. Compilation must terminate, and no source may abort the process, exhaust the native stack, or panic: a source that exceeds a bound must fail compilation with a diagnostic the scope pipeline can hand back to its author, because that channel is what turns a bad pack into a recoverable round rather than a dead run. The obligation covers the compiler's own traversal of the source as well as the evaluation of it. Parsing, module resolution, argument marshalling, and value allocation must each be bounded in depth and in size, iteration and recursion must execute under a bound enforced during evaluation, and every bound must be checked before the work it bounds is done rather than after.

load() must resolve only within the pack directory and must refuse absolute paths, parent traversal, and any path reaching a file outside the pack through a link. The number of modules loaded and their total size must be bounded, and each bound must be counted as a module is admitted rather than once it returns. Loaded content must reach the generated TOML, which remains the runtime authority and the hashed artifact.

Topology is authorable; authority is not. A workflow must declare type = "autoresearch", "custom", or "playbook". An autoresearch workflow's result must be a decide task sourced from a frozen measure or authored grade, with apply and propose ancestors. A custom workflow has no autoresearch-shape requirement. A playbook workflow runs its graph once and carries no judge; RFC-0002 states the rules particular to it. Universal DAG, source-typing, and operation-capability rules apply to all three.

Tasks marked isolated = True must run concurrently in disposable worktrees; their workspace state must be discarded. Only a task's declared output continues through the graph: its JSON output, and any files it declared under emits_files.

session = "name" must preserve one logical agent conversation across dependency-ordered tasks and loop iterations. Tasks sharing a session must not be isolated and must be dependency-ordered.

A required task must not depend, through a path of "all"-join edges, on a task declared advisory. Such a graph asserts both that a failure is tolerable and that the work it gates must pass, and no execution of it can produce an honest verdict. Validation must reject it before dispatch, naming both tasks. A required task joining "passed" or "settled" is exempt: it declares that it runs on whatever settled.

A list of tasks must be accepted wherever a list of task names is, so an author never wraps one to pass it. A task constructed in workflow.star but omitted from workflow(tasks = ...) must be a compile error.

Compile errors must carry file:line:col and a did-you-mean suggestion for unknown functions, kwargs, variables, and session names. A diagnostic about a named argument must locate that argument rather than the whole call, and a suggestion must draw on the names the source itself binds as well as on the DSL's own.

Since: v0.1.0

[RFC-0001:C-PATHS] Path resolution (Normative)

method_prompt, goal_file, and toolbox_dir must resolve relative to the manifest directory.

The agent workspace must resolve to manifest_dir / [workspace].dir.

Runtime state (session.jsonl, admissions.jsonl, control.json) must resolve to --state-dir, default manifest_dir/state.

STEER.md must resolve to --steer, default manifest_dir/STEER.md.

ESCALATION.json must resolve to /ESCALATION.json: written by escalate, consumed by the engine post-turn.

The binary's own install location must never be used to resolve anything.

Since: v0.1.0

[RFC-0001:C-COMMANDS] Command protocol (Normative)

Every command (measure_cmd, apply_cmd, snapshot_cmd, restore_cmd, setup_cmd) must be a string executed via sh -c with cwd = the agent workspace, except setup_cmd which must run with cwd = manifest dir (the workspace does not exist yet).

PATH must be inherited so a command may be a bare installed tool or a workspace-relative script.

measure_cmd is required when [judge] is present. It must inject CRUCIBLE_BASELINE_SCORE, CRUCIBLE_BASELINE_TOTAL, and CRUCIBLE_BEST_SCORE into env when those values are available (absent on baseline measurement). The engine must read the last stdout line that starts with { and parse it as JSON with fields: valid (bool, required), score (number or null), tiebreak (number, optional), solved (bool, optional, default false), note (string, optional), detail (object, optional). A nonzero exit code must force the reading to valid:false regardless of stdout.

apply_cmd, if present, must run after the agent turn and before measure. Nonzero exit must treat the iteration as an invalid candidate (discard).

snapshot_cmd must emit one opaque token on its last stdout line. restore_cmd must receive the token in CRUCIBLE_TOKEN env. snapshot_cmd and restore_cmd must come as a pair.

setup_cmd default when omitted: with [repo] present the engine must git clone [repo] into [workspace].dir and git checkout [ref]; with [repo] absent the engine must create [workspace].dir empty. In every case the engine must then apply [workspace].inject, and the workspace must end up a git repo whose base commit holds the injected files.

Since: v0.1.0

[RFC-0001:C-BUILD-MODE] Build modes (Normative)

Between "the agent edited a file" and "measure_cmd read a score" sits a build step whose shape must be declared per component.

Four modes: no artifact (compile+run in place), no rebuild (config tuning), derive-layer (interpreted sources appended as OCI layer), image (full container build).

derive-layer must require that the base image and push target share a registry. derive-layer must not carry compiled sources.

A compile failure must be distinguishable from a bad score. In image mode, compile error must be returned to the agent as a free retry with no candidate spent.

A Containerfile on the measured path is part of the judge, not part of the solution. If the build recipe lives in the agent's workspace, it must be a frozen = true inject, re-copied before every scored measure.

crucible check should enforce these preconditions before a turn is spent.

Since: v0.1.0

[RFC-0001:C-DECIDE] Decide rule (Normative)

Given a Reading { valid, score, solved, note, detail }, the current best_score, and the manifest direction, the decide rule must be:

keep = valid AND score.is_some() AND (better(score, best_score, direction) OR (score == best_score AND tiebreak_better) OR solved)

better(s, b, lower) = s < b better(s, b, higher) = s > b

tiebreak_better applies only when the reading carries a tiebreak field: better(tiebreak, best_tiebreak, tiebreak_direction). A best with no recorded tiebreak must count as the worst value.

solved must imply keep. A candidate the measure command declares solved must be kept and must terminate the loop even if its score does not strictly beat best. solved must never rescue an invalid reading.

The first valid reading must set the baseline (best_score, and baseline_total = detail.total if present) and must always be kept.

The loop must terminate when a kept iteration is solved, or budget/iterations exhausted, or stop/escalate.

No domain Rust may decide anything. Complex win conditions must be computed inside the measure command using CRUCIBLE_BASELINE_TOTAL and emitting solved.

Since: v0.1.0

[RFC-0001:C-WORLD] World reversibility (Normative)

World::Snapshot = String, opaque to the engine. The engine must only round-trip it back to restore.

GitWorld (default, no [world] commands): snapshot() must stage+commit the workspace and return the commit SHA. restore(sha) must git reset --hard + git clean (excluding .claude/, RESULTS.md). The kept-commit chain is the memory.

CommandWorld (any [world] command given): must always own git memory as above and layer the domain commands. The snapshot token must be the composite "\t". Keep: commit (git half), then run snapshot_cmd and capture its token (domain half), then join. Discard: split, git reset --hard + clean, then run restore_cmd with CRUCIBLE_TOKEN=.

The engine must expose last_commit_sha() (the git half) for kept_shas/publish; the domain half must never be inspected.

The engine's loop body must call only world.snapshot() / world.restore(&snap). It must contain no git/vcs calls and no kubectl.

Since: v0.1.0

[RFC-0001:C-TRANSPORT] Agent transport backends (Normative)

The engine must render a prompt (method_prompt with {{GOAL}}/{{STATUS}}/{{STEER}} filled), hand it + the workspace to an agent that edits the workspace, and must never hand it the Judge.

Three backends, selected by [agent].backend:

local: must run the selected harness directly on the host in the workspace with [agent].env.

openshell: must run the selected harness in a sandboxed pod driven by the OpenShell driver. Two execution environments: the engine and its contract commands always run where crucible runs; only the agent turn is sandboxed. The OpenShell driver must upload the workspace into the sandbox and sync edits back.

command: must run [agent].agent_cmd via sh -c in the workspace as the proposal. This is a deterministic, free proposer (no LLM). It makes the minimal example a fast, deterministic e2e test.

Flipping local to openshell must be config, not code. Reversibility commands (snapshot/restore) must run engine-side regardless of backend.

Since: v0.1.0

[RFC-0001:C-EGRESS] Sandbox egress policy (Normative)

The sandbox must be deny-by-default. Two lists open it: endpoints (host:port:access) and binaries (only these may open a socket).

With inherit_defaults = true (the default), the lists must be appended and de-duplicated to the built-ins (public forges, PyPI, Vertex, Anthropic, agent CLIs). Appending must never remove a built-in.

With inherit_defaults = false, the resolved allowlist must be exactly what the manifest names, binaries included. This is the only way to subtract a default and is required for air-gapped/private-registry runs and contamination control.

The broker endpoint must be auto-appended by the engine when [agent.broker].enabled is true. The engine must first resolve the broker URL, then derive the egress host:port:full entry from that URL's authority. The broker endpoint must be appended regardless of inherit_defaults because it is engine plumbing, not a built-in the domain can subtract.

Since: v0.1.0

[RFC-0001:C-WIRE] Session wire format (Normative)

The NDJSON session log (state/session.jsonl) must keep its existing event kinds (start/phase/row/budget/summary/finished) and field names unchanged. The objective label must be written under the JSON key "gate" (now carrying a free-text label). This must not be renamed (keeps --resume, the remote viewer, and published S3 runs loading).

Additive event kinds:

  • identity: the run's RunIdentity, emitted once at setup and again on --resume.
  • shutdown: { outcome, reason }, emitted exactly once as the last line of every run (after finished/summary). outcome must be one of finished/solved/budget/complete/stopped/escalated/stalled/error. finished means the graph or the loop ran out of work; complete means a task declared there was no work left to do and dispatch stopped short of that, the unscored counterpart of solved. The outcome says how dispatch stopped, not whether the run succeeded. A dead stream with no shutdown line means the pod died mid-run.
  • agent_session: { session, action, turn }, emitted before a persistent agent turn. Must not contain the provider cursor or native transcript content.
  • approval_wait: { handle, trace_id, mode }, emitted when the loop reads a pending-provisioning marker. Every approval_wait must be closed by an approval_resolved except on stop-while-parked and process death.
  • approval_resolved: { outcome, reason } with outcome one of granted/denied/timeout.
  • plan_admitted: { plan_version, reason, budget_usd, tasks }, emitted once after the graph is admitted and before any dispatch. Each task carries its name, kind, dependencies, session, needs, required flag, join, and stage. This is the consumer's route to the graph, so no gate label has to encode it. A consumer computing a verdict must skip tasks whose stage is epilogue, which is why the field is on the wire rather than inferable.
  • asks_emitted: { task, asks }, emitted once per task that emitted any, as that task settles. Each ask carries an emitter-supplied key, the workflow it names, and that workflow's parameter values. The key must be rejected on decode, not only on construction, so the wire is where the key rules are enforced rather than a way around them.
  • recovery: { class, iter, detail }, emitted once per --resume.

RunIdentity is the comparability key: two runs' scores are comparable only if it matches. It must be a hash-of-hashes over: repo URL/path + pristine base commit SHA, frozen manifest text hash, inject content+destination hashes, measure_cmd, and direction.

Since: v0.1.0

[RFC-0001:C-LEDGER] Admission ledger (Normative)

Every external input into a run (steer, approve, deny, rescope, set-budget, pause, resume, stop, abort) must be recorded in state/admissions.jsonl before it takes effect.

Two event kinds: admitted { key, seq, ts, input, ...payload } and settled { key, outcome, ts, note } with outcome one of applied/superseded/rejected.

Per idempotency key: exactly one admitted, then at most one settled. The first terminal outcome wins.

A key with no settled line is an input the run still owes. --resume must re-arm exactly those and close out the ones a resume overrides.

admissions.jsonl is authoritative for what an operator asked for; the session log is authoritative for what the loop was waiting on. Where they disagree, the ledger wins.

Control-bridge commands must accept an optional id (string, non-empty, <= 256 bytes) on every mutating command. Redelivering the same id with the same payload must converge on the original admission. Same id with different payload must be refused. Omitting id must generate a key and every delivery is a fresh input.

A stop/abort whose record cannot be written must still stop the run. Every other command must fail closed if its admission cannot be recorded.

Since: v0.1.0

[RFC-0001:C-SURFACE] Domain author surface (Normative)

A domain author must write:

  1. A crucible.toml manifest.
  2. A measure command emitting { valid, score, solved? } (any language), when [judge] is present.
  3. Optionally apply/snapshot/restore commands.
  4. A method prompt + goal.
  5. Agent credentials in [agent].env.

Everything else (loop, budget, keep/discard, all reporters + remote viewer, steer/stop/resume, session log, escalation, git memory) is the engine's, provided for free.

The litmus test (examples/counter/) must exercise items 1, 2, 4 with GitWorld and the command backend, requiring no Rust.

Since: v0.1.0

[RFC-0001:C-OUTPUTS] Declared output kinds (Normative)

A run's effects on systems outside its workspace divide into mediated writes, performed by the engine or a broker on the agent's request, and open-ended capabilities the agent exercises directly (RFC-0001:C-CAPABILITY-DISCLOSURE). This clause governs the mediated writes.

Every mediated write MUST belong to an output kind drawn from a closed, engine-defined vocabulary. The vocabulary is part of the engine's versioned contract: a kind name, once retired, MUST NOT be reused with a different meaning. A write requesting a kind outside the vocabulary MUST be refused. A vocabulary kind the pack does not declare MUST resolve to a documented engine default, and an engine default MUST carry a count and MUST NOT contain an open target: a pack that ships no [outputs] section gets the conservative posture, not the permissive one. The resolved bounds for a frozen pack MUST be computable without executing pack content, and crucible check MUST print them.

A pack MAY declare bounds for a kind in an [outputs] manifest section. A declaration, once present, MUST carry a per-run count, and, for a kind that addresses a target (a repository, a tracker item, a chat destination, an image registry, a deployment), the target itself. Bounds MUST load from the frozen manifest and MUST be enforced at the mediation point; the agent MUST NOT be able to alter a bound from inside the sandbox.

Where a kind addresses a target, the target MUST come from the resolved declaration, never from an agent-supplied value, unless the declaration explicitly marks the target open. An open-target declaration MUST name a scope narrower than the kind's whole address space; a declaration whose scope admits any target MUST be rejected at manifest validation. A scope MAY bind to a named workflow parameter, and when it does, the target MUST equal that parameter's value for the run; this is how a run fanned out per tracker item confines its writes to the item that parameterized it.

A write that would exceed a count or address a target outside its scope MUST be refused at the mediation point. Every refusal under this clause MUST fail the requesting call naming the violated bound, MUST be recorded on the session log, and MUST NOT by itself terminate the run.

Bounding governs where mediated writes land and how many, not what they say. Payloads remain agent-authored; the reader on the addressed target is the payload's review.

Since: v0.2.0

[RFC-0001:C-CAPABILITY-DISCLOSURE] Opaque capability disclosure (Normative)

Some channels cannot be typed as output kinds because they hand the run open-ended reach: a credential whose value enters the sandbox, egress beyond what the engine's built-ins grant (RFC-0001:C-EGRESS), a relay that materializes host-side secrets into sandbox files, a substitute broker binary, or a mounted credential authorizing writes to an external system such as a cluster namespace. These are capabilities, not outputs, and bounding them by effect is not possible; the contract is disclosure.

A frozen pack MUST disclose every such capability, and the resolved disclosure MUST be computable from the pack without executing pack content. For each capability the disclosure MUST state its reach: for egress, the host, port, and access class in C-EGRESS's terms; for a credential, whether its value enters agent context or remains broker-held, which external system it authorizes, and at what scope; for a relay or a broker substitution, what it draws from and what it exposes. The agent's own credentials, [agent].env included, are credentials under this clause and MUST appear in the disclosure. The built-in egress allowlist is standing disclosed reach: it need not be re-declared, but the resolved disclosure MUST include it, and with inherit_defaults = false every entry the manifest names is disclosed manifest reach, built-in lookalikes included.

The engine MUST derive what it provisions and what it discloses from the same resolved declaration, so the two cannot diverge, and crucible check MUST print the resolved disclosure.

A capability granted from outside the pack, such as a secret binding supplied at launch, is covered when a disclosed capability of the same kind has reach equal to or broader than the grant's. A grant that is not covered MUST be refused at run start, and the refusal MUST name the grant and the missing disclosure.

Sandbox-resident pack content, skills included, holds no reach of its own: what it can touch is the union of the resolved disclosure and the declared output kinds. Pack-authored commands that execute outside the sandbox (workflow command and evaluate tasks, world and judge hooks) hold their executor's reach instead, and the disclosure MUST state that the pack runs commands outside the sandbox whenever it does. Narrowing that executor's reach is outside this clause; disclosing that it exists is not.

Since: v0.2.0


Changelog

v0.2.0 (2026-08-22)

Admit the playbook lane

Changed

  • DSL grammar admits dictionaries, conditionals, comprehensions, iteration, user-defined functions, load() and the pure standard library
  • compilation must be bounded in depth and size and must not abort the process on an author-supplied source
  • workflow type may be playbook
  • a task's declared files continue through the graph alongside its JSON output, isolation notwithstanding
  • a required task may not depend on an advisory one through all-join edges
  • param joins the enumerated constructors
  • shutdown outcome gains complete for a run a task ended with no work left
  • a named-argument diagnostic must locate the argument, and suggestions draw on source bindings
  • session log gains the plan_admitted event, so a consumer reads the graph instead of inferring it from a gate label
  • deps() is removed: depends_on already accepts task values, so the wrapper was ceremony
  • the declared lane scopes the constructor namespace, so a playbook cannot name or be offered a scored constructor
  • session log gains asks_emitted, so what a run proposed is auditable apart from what an orchestrator admitted
  • skill joins the enumerated constructors: an agent task whose prompt is assembled from a shipped SKILL.md and the invocation's arguments

v0.1.0 (2026-08-19)

Initial draft