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.

[repo].ref, when present, names the branch, tag, or commit the repository is cloned at. It MUST be a valid git ref name, short (main, feature/x, v1.2) or fully qualified (refs/heads/main), or a full commit id, and MUST NOT begin with "-", which git would read as an option. A ref that breaks either rule MUST be rejected as a manifest error naming it. A ref a launch supplies is held to the same rules.

When a ref is set, the engine MUST fetch it from the clone source and check it out detached, so that a short or fully qualified branch or tag, a refs/pull/ ref, and a full commit id all resolve. A commit the clone already holds need not be fetched. A ref that cannot be fetched or checked out MUST fail setup naming the ref; the engine MUST NOT run on any other commit, and MUST remove the clone so that no later run reuses it.

[repo.overrides].refs lists the refs a launch may check out instead of [repo].ref (RFC-0002:C-PLAYBOOK-PARAMS). Each entry is a glob over branch and tag short names: * matches within one /-separated segment and ** across segments, and a branch and a tag of the same short name match alike. An entry that is empty, begins with "-", is not a valid glob, or begins with "refs/" other than "refs/pull/" MUST be rejected at manifest validation. A refs/pull/ ref MUST match only an entry that begins with "refs/pull/", so that a broad entry never admits code from a fork. [repo.overrides] MUST be rejected at manifest validation when [repo] has neither url nor path, and beside [workspace].setup_cmd, which replaces the clone.

A launched ref MUST be refused before anything is cloned when [repo.overrides].refs lists no entry, when no entry matches it, or when the manifest lives inside the workspace it targets, since the checked-out ref would then supply its own manifest. A full commit id is matched once cloned instead: the engine MUST keep it only when a branch or tag of the fresh clone that an entry matches contains it, and otherwise MUST refuse the run and remove the clone. A fresh run with a launched ref MUST refuse a workspace that already exists. A resumed run and a rerun start from the commit their run recorded (RFC-0002:C-PLAYBOOK-RESUME, RFC-0002:C-PLAYBOOK-RERUN), not from the ref.

[agent.overrides].allow lists the agent settings a launch may set on the workflow's agent tasks (RFC-0002:C-PLAYBOOK-PARAMS). Its entries MUST be drawn from harness, model, and effort; any other MUST be rejected at manifest validation. [agent.overrides].models, when non-empty, lists the models a launch may name; it MUST be rejected unless allow lists model, and each entry MUST be a model name as RFC-0002:C-PLAYBOOK-PARAMS defines one. With no allow entries a launch sets no agent setting. A composite manifest MUST reject [agent.overrides].

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

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

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

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

Unknown manifest keys must be rejected as errors.

A manifest may declare variants as [variant.] tables, each holding top-level manifest tables, with names of 1 to 64 ASCII letters, digits, '-' or '_'. A run reads the base manifest, the document without its variant tables, unless it names a variant; it then reads the base with each top-level entry the variant declares in place of the base entry of the same name, replaced whole, with nothing merged below the top level. A variant must not declare variants of its own. Every declared variant must be parsed and validated whenever the manifest is loaded, whichever is selected, and naming a variant the manifest does not declare must be refused naming the declared ones. A composite manifest declares no variants.

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

Since: v0.1.0

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

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

The protocol must:

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

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

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

Since: v0.1.0

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

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

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

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

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

Since: v0.1.0

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

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

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

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

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

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

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

A task may declare emits, the fields its JSON output promises, as a list of field names or as a dictionary from field name to type. A type is one of "string", "integer", "number", "boolean", "list", "object", a non-empty list of distinct labels, which declares a string equal to one of them, or a schema_file(), which declares a value its JSON Schema admits. Labels must be identifiers, as route labels are. "integer" admits a number with no fractional part, and "number" admits any number. Both forms must reach the generated TOML unchanged, and the plan_admitted event must carry each declared field with its type when it has one, and each declared file with its schema when it has one.

schema_file() must resolve only within the pack directory under the confinement load() has, and its size must be bounded and charged to the same budget as prompt_file(). The file must be a JSON Schema of draft 2020-12 that declares no other $schema and references nothing outside itself; anything else must be a compile error at the call. The engine must not fetch a referenced schema. The schema's content, not its path, must be compiled into the plan, so editing it changes the plan. On the wire a schema is its JSON text. An empty or absent emits declares nothing and is never checked.

A passing attempt whose output lacks a declared field, or holds a declared field whose value is not of its declared type, must settle as a measured failure at the producing task: it must not be retried, its note must name the field, and the output must be kept as the evidence. For a schema, the note must give the instance path and message of each of the first few errors and must not repeat the refused value.

An agent task may declare repair = N, with N from 0 to an engine ceiling of 3; a command or evaluate task must refuse it at compile time, because running it again produces the same output. When an attempt of a task with repair would pass but its output lacks a declared field, holds one that is not of its type, or lacks or breaks a declared file (RFC-0002:C-TASK-FILES), the engine must resume the same agent conversation, give it every such note, masked as above, and check the result again, at most N times, before the attempt settles. A repair must not start a new conversation, must run within the attempt's deadline, must count its cost toward the attempt and the run's budget, and must not start once the attempt has spent what the run had left when it was dispatched. A repair is not a retry: the attempt count does not change, and when repairs run out the attempt settles as the measured failure it would have been without them. Each mapped instance repairs on its own. Each repair turn must be recorded on the task's result with its round, its cost, and the notes it was given. A task that settles itself skipped or failed owes nothing.

Validation must check the declared types against every consumer that reads a field, before dispatch. A task mapped over producer.field must name a field its producer declares, and when that field is typed, a "list". A top_k dependency, a grade score source or tiebreak, and a thresholded evaluate that declare emits must declare score, and when it is typed, a "number" or an "integer". An output-decided route reading a question from a source whose emits types that question's field must find every value of the type answerable under RFC-0001:C-ROUTE: a label list whose every label is one of the question's labels or "uncertain", or, for a noul, "boolean". Any other type, "string" included, must be rejected, because it promises nothing about which label arrives. A field declared by a schema must satisfy each consumer exactly, as far as the schema can be read without evaluating it. Its values are bounded by its const, its enum, or a boolean type, read through local $ref and allOf. Mapped over, its top-level type must be "array" and every item must provably be a string, because a mapped instance is named by its item: the items and every prefixItems entry must have type "string", or a const or enum of strings only, directly, through a local $ref or an allOf member, or in every branch of an anyOf or oneOf; a schema that declares no items, or whose items admit anything else, must be rejected naming what it found. Read as a score, its top-level type must be "number" or "integer" and any const or enum must hold numbers only. Read by an output-decided route, its bounded values must all be answers the question accepts, a boolean answering "yes" or "no"; a schema that bounds nothing must be rejected as "string" is. A schema whose top-level type is absent or a list must be rejected wherever a consumer needs a type.

When an output-decided route's source bounds a question's field to finitely many answers, by a label list, "boolean", or a schema as above, a when that names a label the source can never give must be rejected as never answered, naming the label, the source, and the answers it can give. Such a label need not be routed, and otherwise must expand only to labels the source can give. A field declared without a type is checked for presence only.

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

An agent or skill task MAY set harness, model, and effort, and a session MAY declare them for the tasks it holds. A harness or effort that no agent turn can read MUST be rejected when the graph is validated, before any task is dispatched, naming the task, the setting, and the values accepted; values are read without regard to case. This holds whether the graph was compiled from workflow.star, carried inline as [workflow] tasks, or loaded from a plan file. A launch may change these settings per task under RFC-0002:C-PLAYBOOK-PARAMS.

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

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

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

Since: v0.1.0

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

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

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

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

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

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

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

Since: v0.1.0

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

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

A manifest states the edition of the pack contract it is written against with a top-level edition key; a manifest without one is edition 1. The engine must refuse at parse a manifest whose edition it does not support, naming the editions it supports. Under edition 1, measure_cmd, apply_cmd, snapshot_cmd, restore_cmd, and every workflow command and evaluate task run with the engine's environment and the variables the engine sets for that command; the engine must refuse at parse an edition-1 manifest whose [judge].env, [world].env, or [workflow].env names a variable, naming the table and edition 2, and crucible check and the start of every run must warn that the pack's commands inherit the engine's environment. Under edition 2, measure_cmd, apply_cmd, snapshot_cmd, restore_cmd, and every workflow command and evaluate task must run with an environment holding only PATH, HOME, LANG, TMPDIR, and the run's identity (CRUCIBLE_RUN_NAME, CRUCIBLE_RUN_DISPLAY_NAME, and CRUCIBLE_UI_BASE_URL) as the engine has them, the variables the engine sets for that command (the scores and token this clause names, and a workflow task's name and inputs), and the variables its manifest table declares: [judge].env for measure_cmd, [world].env for apply_cmd, snapshot_cmd, and restore_cmd, and [workflow].env for workflow command and evaluate tasks. Nothing else of the engine's environment may reach them, since a command can be a script the agent was able to edit. PATH is inherited so a command may be a bare installed tool or a workspace-relative script. The run's identity names the run and links to it and grants nothing; the pod token, and the controller URL it authenticates against, must reach such a command only by its table's declaration. A declared entry must be an environment variable name (a letter or underscore followed by letters, digits, and underscores), optionally followed by a single ?. A name without ? is required: the engine must refuse the run before any of these commands runs, naming every required variable its environment does not set. A name followed by ? is optional: the command must receive it when the engine's environment sets it and must run without it when it does not, and its absence must not refuse the run. An entry with ? anywhere but once at its end, and a name a table declares both required and optional, must fail the manifest's parse, naming the entry. setup_cmd runs with the engine's environment in every edition.

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

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

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

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

Since: v0.1.0

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

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

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

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

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

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

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

Since: v0.1.0

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

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

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

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

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

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

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

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

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

Since: v0.1.0

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

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

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

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

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

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

Since: v0.1.0

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

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

Three backends, selected by [agent].backend:

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

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

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

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

Since: v0.1.0

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

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

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

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

The broker endpoint must be auto-appended by the engine when [agent.broker].enabled is true. The engine must first resolve the broker URL, then derive the egress host:port:full entry from that URL's authority. The broker endpoint must be appended regardless of inherit_defaults because it is engine plumbing, not a built-in the domain can subtract. The same holds for the gateway forward of each service a task in that sandbox needs under RFC-0005:C-SERVICE-REACH: the engine must append it regardless of inherit_defaults, and must allow the agent harness's executable to open that connection whatever binaries list the manifest names.

Since: v0.1.0

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

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

Additive event kinds:

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

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

Since: v0.1.0

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

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

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

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

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

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

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

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

Since: v0.1.0

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

A domain author must write:

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

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

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

Since: v0.1.0

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

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

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

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

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

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

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

Since: v0.2.0

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

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

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

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

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

Sandbox-resident pack content, skills included, holds no reach of its own: what it can touch is the union of the resolved disclosure and the declared output kinds. Pack-authored commands that execute outside the sandbox (workflow command and evaluate tasks, world and judge hooks) hold their executor's reach instead, within the environment RFC-0001:C-COMMANDS gives them, and the disclosure MUST state that the pack runs commands outside the sandbox whenever it does. Under edition 2 (RFC-0001:C-COMMANDS), each variable a manifest table declares for those commands, optional ones included, is reach they hold, and the disclosure MUST name it together with the table that declares it and mark it optional when it is. Under edition 1 those commands hold the engine's whole environment, and the disclosure MUST state that for each table whose commands run.

Since: v0.2.0

[RFC-0001:C-ROUTE] Typed model decisions and conditional tasks (Normative)

A route task answers a fixed set of questions about the outputs of its dependencies and emits one typed answer per question. The engine owns the decision: no agent turn runs, and the task's output is the engine's record of it. A route must declare exactly one decider. A model decider asks a decision model. An output decider reads the answers from one named dependency's output, and is deterministic given that output. A human decider takes the answers from a person allowed to approve the run, under RFC-0002:C-HUMAN-DECISION.

A route must declare at least one question. Each question has an id, instructions, and a type. A noul question answers "yes" or "no". A choice question answers exactly one of its declared options, of which there must be at least two, all distinct. A score question answers one of its ordered levels under RFC-0001:C-ROUTE-SCORE. Question ids and option labels must be identifiers, and the label "uncertain" is reserved. Questions, options, and thresholds are authored at compile time and must reach the generated TOML; nothing a task emits at run time may add, remove, or rename one, except the options of a dynamic choice, which a dependency supplies under RFC-0001:C-ROUTE-OPTIONS.

The state sent to a model decider is the route's dependency inputs, exactly as any other task would receive them under its join. It is untrusted data. Whatever it contains, the answer to a question must be one of that question's declared labels or "uncertain", and the engine must fail the task rather than forward any other value.

A model-decided route must declare min_confidence in (0, 1]. When the probability the model assigns its chosen label is below it, the engine must record the answer as "uncertain". The task's output must carry, for each question, the resolved label and the full probability distribution the model returned, so the record that resume folds under RFC-0002:C-PLAYBOOK-RESUME is the decision itself and a resumed run never asks again.

An output-decided route must name a task among its dependencies. That task's output must carry, under each question id, one of the question's declared labels or "uncertain" as a string; a noul also accepts a boolean. A missing field, any other value, or any other type must fail the route. When the named task declares emits, validation must reject a question id the declaration omits, and, where the declaration types that field, a type that cannot answer the question under RFC-0001:C-WORKFLOW. The recorded answer carries probability 1 for the label read, and an empty distribution with confidence 0 for "uncertain". An output-decided route makes no network call, spends nothing, and needs no capability.

A human-decided route must declare needs = "human". It alone may ask a multiple choice or a pick, whose options come from a dependency's output, and it must not map over a list. Its request, wait, timeout, and recorded answer are governed by RFC-0002:C-HUMAN-DECISION, and what the approver is shown by RFC-0002:C-DECISION-EVIDENCE.

The engine must reach the decision model through the broker, at an endpoint and credential the controller configures and the workflow cannot name. A model-decided route task must declare needs = "decision", verified before dispatch under RFC-0002:C-PLAYBOOK-CAPS. The request and response follow the decision API the binding's protocol names under RFC-0001:C-INFERENCE. For system_one, the System One decision API: a state object and a map of question id to type, instructions, and options. For decisions, the OpenAI Decisions API: the state serialized as JSON in the text input, each noul asked as a predicate question and each choice as a choice question, named by its question id. Either API answers a noul with a probability and a choice with per-option probabilities, and the engine must derive the label from those probabilities alone, never from a choice or confidence the API reports. A question the API declines to answer must be recorded as "uncertain" with confidence 0 and an empty distribution. A response that omits a question, answers it twice, answers it as another type, names an undeclared option, or carries a probability outside [0, 1] must fail the task. Failure to reach the endpoint is a transport failure and retries as one.

Any task may declare when, a predicate over one question of one route task it depends on, satisfied when that question's resolved label is among the labels the predicate lists. Validation must reject a when that names a task that is not a route, a task outside the declaring task's dependencies, an unknown question, an empty label list, or a label that is neither declared by the question nor "uncertain", each with a diagnostic at the argument and a did-you-mean over the declared names. Every label of a routed question, including "uncertain", must either be listed by at least one when or be named in that question's drop list, so an answer that leads nowhere is one the author wrote down rather than one they forgot. A drop list must name only that question's labels or "uncertain". A question no when refers to is exempt.

A task may declare otherwise in place of answers. The compiler must expand it to the question's labels, "uncertain" included, that no other task's when lists and the drop list does not name, and must write that list to the generated TOML. Tasks declaring otherwise on the same question get the same list. An otherwise that expands to nothing, otherwise with answers, otherwise without when, and an empty answers list must each be a compile error at that argument.

A reviewer under RFC-0002:C-REVISE-LOOP must not declare when: a reviewer runs whenever what it revises does, so the condition belongs on a target, and validation must reject the reviewer's. A target's when is evaluated in every round as any task's is; where the first round dispatches no body task, no round rows are written and the reviewer settles as its join makes it.

A task whose when is not satisfied must settle as not taken, without dispatch and without spend. A when over a route that itself settled not taken is not satisfied. A when over a route that settled any other way without passing is not satisfied either, except that a task joining "all" on that route is blocked by the failure as any dependent is. Not taken is a terminal status distinct from skipped. It must not count against validity, must not short-circuit the run, and must not be reported as a failure. A task joining "all" on a dependency that settled not taken must itself settle not taken, so an untaken branch is untaken to its leaves, unless the task declares that dependency optional. A task joining "passed" or "settled" is dispatched as those joins already define, and is how branches rejoin.

A task joining "all" may declare dependencies optional, so it runs whether or not a conditional task it reads was taken. An optional dependency that settled not taken must satisfy the join as a passing dependency does, and the task must receive no input entry for it; an optional dependency that settled any other way without passing must block the task as any dependency does. Validation must reject an optional dependency that is not among the task's dependencies, one declared twice, one declared by a task whose join is not "all", one the task revises under RFC-0002:C-REVISE-LOOP, and one that can never settle not taken: a task with no when that joins "all" on no conditional task through a dependency it does not declare optional. A required task is required when taken: if its when is satisfied it gates validity as any required task does. The workflow's result task must not be conditional and must not be able to settle not taken through "all" joins on dependencies not declared optional.

A route task may declare over. RFC-0001:C-ROUTE-EACH states how a mapped route decides each element of a list and how a when reads that decision; where it and this clause differ for a mapped route, it governs.

The route and when constructs are admitted in the playbook and custom lanes. An autoresearch workflow must reject both: the keep rule is sourced from a frozen measure, and a model decision must not sit on the path to it.

Since: v0.2.0

[RFC-0001:C-INFERENCE] Injected inference bindings (Normative)

An orchestrator tells the engine where models are reached through one document, the resolved inference bindings, carried as JSON in the environment variable CRUCIBLE_INFERENCE. The document is defined once, in the contract crate both sides compile, and carries a wire version. The engine must reject a document whose version it does not know or that has an unknown field, before it clones, spends, or writes anything, with an error that names the version or the field. Absent or empty, the run has no bindings.

A binding states what the inference is for and how it is reached: a role, a protocol, a model, optionally a URL, and optionally the name of the environment variable that holds its credential. An absent URL means the protocol's own service address: for decisions, OpenAI's https://api.openai.com/v1/decisions. A system_one binding must name one, because system_one has none. The document must never carry a credential's value. The roles are agent, the model an agent turn thinks with, and decision, the model a route task asks under RFC-0001:C-ROUTE. The protocols are messages, chat_completions, responses, system_one, and decisions. A document must hold at most one binding per role. An agent binding must speak messages, chat_completions, or responses; a decision binding must speak system_one or decisions. A URL must be absolute http or https, name a host, and carry no credentials. A credential variable name must be a valid environment variable name and must not be CRUCIBLE_INFERENCE. The engine must reject a document that breaks any of these, naming the binding.

A binding says nothing about delivery. The engine derives it from the role's consumer. Where the engine's own process is the client, as for a decision, the engine must use the binding directly and the credential must not leave the engine. Where the client runs inside a sandbox, as an agent harness does, the engine relays: it must project the binding into the sandbox under the names that client reads, and must add the binding's endpoint to the sandbox's egress policy. Those names are the engine's concern and are not part of this contract; an orchestrator must not be required to know them. A decision binding has no relayed consumer: the engine must not project it, its endpoint, its credential, or any interface that reaches it into any sandbox or task process, so a model decision is made only by a route task under RFC-0001:C-ROUTE.

The decision capability of RFC-0002:C-PLAYBOOK-CAPS is available exactly when the document holds a decision binding. A named credential variable that is unset or empty when its binding is first used must fail that use with an error naming the variable, and must not be treated as an unauthenticated endpoint.

An agent binding is the whole answer for agent turns. Its model is the run's model, over the manifest's agent defaults and over a model the launch named; a task that names its own model keeps it. Its protocol constrains the harness: the harness the launch or the manifest selected is kept when it is a client of that protocol, and otherwise the engine must select the harness that is. Its credential takes the place of any credential the pack's manifest selects, in every authentication mode that manifest names. When an agent binding is present the engine must not consult the process's ambient inference variables. The engine must log each harness, model, or credential a binding replaces. An agent binding with no credential variable authenticates with the deployment's ambient credentials, as a run with no agent binding does. A run with no agent binding reaches its model the way its manifest and the process's ambient environment say, which is how an operator's own shell runs one.

An agent binding may carry a price: four finite, non-negative rates in USD per million tokens, for input, output, cache-read, and cache-write tokens. The engine must reject a price on any other binding, naming the binding. The engine must cost each agent turn of the run this way. With a price, the turn costs its token counts times those rates, whatever model the turn runs and whatever cost the harness reports. Without one, a turn on a model a vendor bills for costs what the harness reports, or its token counts times the engine's own rates for that model when the harness reports nothing. A turn on any other model costs nothing, whatever the harness reports, because nobody bills for it. That cost is what the run's cost ceiling (RFC-0002:C-PLAYBOOK-BUDGET) counts.

Since: v0.2.0

[RFC-0001:C-ROUTE-EACH] Per-element routes (Normative)

A route task may declare over = producer.field with max_fanout, under the rules for over in RFC-0001:C-WORKFLOW: the field must be a list of strings, and the route's instance for an element is named by that element's item, its key. A mapped route decides once per element. Each element's decision is one request to the decision model, transport retries aside, or one read for an output-decided route, and its instance settles on its own: its output is that element's decision in the form RFC-0001:C-ROUTE defines for a route, and an element that fails or transport-fails must not change the decision recorded for any other element. The engine must check the run's cost and wall-clock ceilings before each element's request as it does before any dispatch, and an element not yet asked when a ceiling is exhausted must settle blocked; the cost ceiling blocks only an element whose decision asks the decision model, as RFC-0002:C-PLAYBOOK-BUDGET requires.

Two tasks are aligned when both declare over naming the same producer and the same field. Every edge from a task to a dependency aligned with it is read per element, whatever kind of task either is. Across such an edge the dependent must not expand until the dependency has settled as a node, and must then expand whatever the node's status, unless dispatch has stopped for another reason. Its instance for an element must receive the dependency's instance for the same key in place of the node: under "all" or "passed" that instance's output under the dependency's name, and under "settled" that instance's entry as RFC-0002:C-SETTLED-JOIN defines it, with no per_instance mapping. The instance must apply its join to that instance's status in place of the node's, and must read an absent instance as not taken when the dependency's node settled not taken and as blocked otherwise. An instance joining "all" on an aligned dependency whose instance for the same key settled not taken therefore settles not taken, so an untaken element is untaken to the leaves of its aligned chain as RFC-0001:C-ROUTE requires of a branch.

The state sent for an element must be the route's dependency inputs, read per element across aligned edges, with the element's key added under the reserved key "item". A mapped route must not depend on a task named "item", and validation must reject one that does.

Any mapped task, a route or not, may declare keyed, a list of producer.field references. Each must name a field declared by one of the task's dependencies that is not mapped, and where the declaration types it, as "object" or by a schema whose top-level type is "object"; validation must reject any other, naming the reference, and must reject keyed on a task that does not declare over. For each keyed field, the inputs an instance receives, and for a route the state it sends, must carry in place of the whole field only the field's entry under the instance's key, read from the producer's output whatever the task's join; the producer's other fields are unchanged. A keyed value that is not an object at run time must fail every instance, naming the field, and an absent entry must fail that instance, naming the key and the field. keyed must reach the generated TOML, and the admitted-plan event must carry each task's keyed references as a list of "producer.field" strings, empty for a task that declares none, so a reader can draw which edges are narrowed.

An output-decided route that declares over reads each element's answers from its source per element. Where the source is aligned with the route, the answers are read from the source's instance for the element, under the rules RFC-0001:C-ROUTE states for a source's output. Otherwise the source must not be mapped, and its output must carry, under each question id, an object keyed by element key whose entry for the element is the answer under those rules; validation must reject a source whose emits types a question's field as anything but "object" or an object schema, and an absent entry must fail that element.

A when that names a mapped route must be declared by a task aligned with that route. Validation must reject a when on a mapped route declared by a task that is not mapped, or that maps over a different list, naming both references. Such a when is evaluated per element: the dependent's instance for an element applies the rules of RFC-0001:C-ROUTE to the route's instance for the same key, so it settles not taken, without dispatch and without spend, when that element's resolved label is not among the labels the when lists, and the instances for other elements are unaffected. The rules of RFC-0001:C-ROUTE for drop, otherwise, and label coverage apply to a mapped route's questions unchanged. Several tasks aligned with one mapped route may each declare a when on the same question, and so partition the elements among them by answer. A mapped route may itself declare a when on an aligned route, so it is asked only about the elements that route's answer leaves to it. A when on a route that does not declare over, declared by a mapped task, is evaluated once for the node under RFC-0001:C-ROUTE.

A mapped task's instance that settled not taken must not count as failed. A mapped node with no instances must settle passing. Otherwise it must settle not taken when every instance was not taken, passing when every instance passed or was not taken, and failing otherwise. Its folded output must carry the number of instances not taken beside the numbers that passed and failed, and must carry no output for an instance not taken. The task-result event's fan-out summary must carry the same count under not_taken; a reader that predates it reads a summary without it as zero.

Each element's decision is its instance's recorded output, and an instance settled not taken is terminal. On resume, under RFC-0002:C-PLAYBOOK-RESUME, the engine must fold both, must not ask again for an element that has a recorded decision, and must ask only for the elements that have none.

Since: v0.2.0

[RFC-0001:C-ROUTE-SCORE] Score questions (Normative)

A question may be a score: an ordered list of at least two distinct levels, lowest first, each an identifier with a description, in the order the author declares them. A score is a third question type beside noul and choice, constructed in the DSL by score() in the lanes that admit route under RFC-0001:C-WORKFLOW. Its labels are its levels, and every rule RFC-0001:C-ROUTE states for a choice's options applies to its levels: identifiers, the reserved label "uncertain", when, drop, otherwise, and label coverage. The levels and their order must reach the generated TOML, and nothing a task emits at run time may change them.

A model-decided route asks a score question for a probability per level and must validate the answer as it validates a choice's. The engine must fail the task when every level's probability is 0, and must otherwise normalize the distribution to sum 1. The label is the level with the highest normalized probability, the lowest such level on a tie, and is recorded as "uncertain" when that probability is below min_confidence. The recorded answer must carry, beside the label, confidence, and distribution that RFC-0001:C-ROUTE requires, a number under "score": (Σ p(i) · i) / (n − 1) over the normalized distribution, for level i of n counted from 0, so that 0 is the lowest level and 1 the highest. The engine must compute the score from the distribution alone and must not read a score or confidence the API reports. The score must be recorded whatever the label, including when the label is "uncertain".

Where the decision API the binding speaks has a native question over ordered levels, the engine must ask a score question as that question, with the levels in their declared order. Where it has none, as the System One API has none, the engine must ask the question as a choice over the levels and resolve it as above. Either way the recorded answer must name the form it was asked in under "asked_as", as "score" or "choice". A response that answers a score question as any type other than the one it was asked as must fail the task.

An output-decided route answers a score question from one level label, read as RFC-0001:C-ROUTE reads a choice's; the recorded score is that level's position divided by n − 1, and "uncertain" records no score. A source whose emits types the field must type it as a label list within the levels and "uncertain", or as a schema whose bounded values are among them; "boolean" must be rejected.

Since: v0.2.0

[RFC-0001:C-ROUTE-FILES] Declared files in a route's state (Normative)

A route may declare files, a list of workspace-relative paths. Each path must be declared under emits_files by at least one of the route's dependencies with a JSON Schema (RFC-0002:C-TASK-FILES); validation must reject a path no dependency declares, a path every declaring dependency declares without a schema, and a path declared only by a mapped dependency that is not aligned with the route under RFC-0001:C-ROUTE-EACH, naming the path.

The state sent to the decision model must carry, under the reserved key "files", one entry per dependency that declares a listed path, under that dependency's name, mapping each such path to the parsed content of the file staged for the route. For a route mapped over a list, an aligned dependency's file is its instance's for the same element. A dependency whose file was not staged for the route contributes no entry for that path. A staged file that does not parse as JSON, as a failed dependency's file under a "settled" join may not, must contribute null in its place, and the engine must record the path on the route's result. A route must not depend on a task named "files", whether or not it declares files, and validation must reject one that does. A route that declares no files sends no "files" key.

An output-decided route reads its answers from its source's JSON output and must reject files at compile time, because it asks no model.

Since: v0.2.0

[RFC-0001:C-ROUTE-OPTIONS] Dynamic choice options (Normative)

A choice question may name, in place of its options, a field a dependency of the route emits, constructed in the DSL as choice(options = producer.field). Such a choice is dynamic: its options are read at run time, once for each decision, and only the reference reaches the generated TOML. A model-decided or a human-decided route may ask a dynamic choice, and validation must reject one on an output-decided route. A human-decided route resolves it when it opens its request, and the request carries it as the choice it resolved to under RFC-0002:C-HUMAN-DECISION.

The reference must name a field of one of the route's dependencies, and that dependency must not be mapped. For a route that does not declare over, the field is the option list. For a route that declares over, the field is an object keyed by element, as a keyed field is under RFC-0001:C-ROUTE-EACH, and an instance's option list is the entry under its key, read from the producer's output whatever the route's join and keyed. Validation must reject, naming the reference, a reference to a task outside the route's dependencies, to a mapped task, or to a field the producer's emits omit, and, where the emits type the field, a type other than "list" or an array schema for a route that does not declare over, and other than "object" or an object schema for one that does.

An option list must be a JSON array of at most 64 entries, a bound the engine must check before it reads any entry. Each entry is a string, which is the option's label, or an object with a string "value", which is the label, an optional string "description" the model sees, and no other key. Each label must be an identifier other than "uncertain". An entry whose label repeats an earlier entry's is dropped, and the earlier entry is kept with its description. What remains must hold at least two options. An absent field, an absent entry for the key, a value that is not a list, a list over the bound, an entry of another shape, a label that is not an identifier or is "uncertain", and fewer than two options after duplicates are dropped must each fail that decision before the decision model is asked or the request is opened, with a note naming the field, the key where there is one, and the violated bound or entry. Under RFC-0001:C-ROUTE-EACH such a failure must not change the decision recorded for any other element.

The resolved options are asked in the order the list gives them, exactly as a declared choice's options are under RFC-0001:C-ROUTE, and every rule there for answers, probabilities, min_confidence, and refusals applies to them. The recorded answer must carry, beside what RFC-0001:C-ROUTE requires, the resolved options under "options", each as its label and, where one was given, its description, whatever the answer, "uncertain" and a refusal included, so the record shows what the decider chose among. An answer to any other question must not carry options.

A dynamic choice declares no labels at compile time. A when on one must list no label but "uncertain", and its drop list must name nothing but "uncertain"; validation must reject any other label, saying the question's options arrive at run time. A task may declare otherwise on a dynamic choice. The compiler must expand it to every option the question resolves to, together with "uncertain" when no other task's when lists it and the drop list does not name it, and must write the expansion to the generated TOML as the listed labels and a flag that stands for any option. A when carrying that flag must be satisfied by any resolved label other than "uncertain", and validation must reject the flag on a question that is not a dynamic choice. For label coverage under RFC-0001:C-ROUTE, the options of a routed dynamic choice count as one label, covered only by a when carrying the flag.

Since: v0.2.0


Changelog

v0.2.0 (2026-08-22)

Admit the playbook lane

Added

  • route, noul, choice constructors and the when predicate; not-taken terminal status (C-ROUTE)
  • a route decides from a model or, deterministically, from a dependency's output
  • injected inference bindings, one typed document for every model the engine or its sandboxes reach (C-INFERENCE)
  • otherwise, resolved by the compiler to the answers no other task lists
  • typed emits: a field may declare its JSON type or labels, checked at run time at the producer and at compile time against over, score readers, and output-decided routes (C-WORKFLOW)
  • schema_file(): a field may declare a JSON Schema (2020-12) read from the pack; its content is compiled into the plan and consumers read its top-level type (C-WORKFLOW)
  • repair = N on agent tasks: a passing turn whose output breaks its declaration resumes the same conversation with the masked notes, within the attempt's deadline and the run's budget (C-WORKFLOW)
  • schema-typed fields meet each consumer exactly: string items for over, numeric enums for scores, bounded answers for routes; a when label a typed source can never give is refused (C-WORKFLOW)
  • a route may be human-decided: needs = "human", governed by RFC-0002 (C-ROUTE); session log gains decision_wait (C-WIRE)
  • a route may map over a list and decide each element on its own; a task reads a dependency mapped over the same list per element, a when on a mapped route is read per element, and an instance not taken does not fail its fan-out (C-ROUTE-EACH)
  • keyed: a mapped task may narrow an object field of an unmapped dependency to its own element's entry, and the admitted-plan event carries it (C-ROUTE-EACH)
  • score questions: ordered levels answered by a per-level distribution and recorded with an engine-computed expected position; asked natively where the decision API has an ordered question and as a choice where it has none (C-ROUTE-SCORE)
  • a route may put declared JSON files of its dependencies in the decision model's state (C-ROUTE-FILES)
  • [repo.overrides] and [agent.overrides]: static bounds on the refs and per-task agent settings a launch may change (C-MANIFEST)
  • a choice may read its options from a dependency's field at run time, per element on a mapped route, bounded at 64 and recorded with the answer; a when on it lists only uncertain or declares otherwise (C-ROUTE-OPTIONS)

Changed

  • DSL grammar admits dictionaries, conditionals, comprehensions, iteration, user-defined functions, load() and the pure standard library
  • compilation must be bounded in depth and size and must not abort the process on an author-supplied source
  • workflow type may be playbook
  • a task's declared files continue through the graph alongside its JSON output, isolation notwithstanding
  • a required task may not depend on an advisory one through all-join edges
  • param joins the enumerated constructors
  • a route task may declare over (C-ROUTE)
  • a decision binding is never relayed into a sandbox or task process, so only a route asks the decision model (C-INFERENCE)
  • shutdown outcome gains complete for a run a task ended with no work left
  • a named-argument diagnostic must locate the argument, and suggestions draw on source bindings
  • session log gains the plan_admitted event, so a consumer reads the graph instead of inferring it from a gate label
  • deps() is removed: depends_on already accepts task values, so the wrapper was ceremony
  • the declared lane scopes the constructor namespace, so a playbook cannot name or be offered a scored constructor
  • session log gains asks_emitted, so what a run proposed is auditable apart from what an orchestrator admitted
  • skill joins the enumerated constructors: an agent task whose prompt is assembled from a shipped SKILL.md and the invocation's arguments
  • a decision binding may speak decisions, the OpenAI Decisions API, reached at OpenAI's address when it names no URL (C-INFERENCE, C-ROUTE)
  • the capability a model-decided route needs is decision, not systemone, since it names the role rather than one API (C-ROUTE)
  • C-ROUTE-EACH: the cost ceiling blocks only an element whose decision asks the decision model (RFC-0002:C-PLAYBOOK-BUDGET)
  • C-MANIFEST: [repo].ref follows git's ref-name rules and never begins with -; the clone fetches the ref and checks it out detached, and a ref that does not resolve fails setup instead of running on the default branch
  • C-WORKFLOW: a harness or effort no turn can read is rejected at validation instead of at dispatch
  • C-ROUTE: an all-join task may declare a dependency optional, so one that settled not taken satisfies the join and gives no input entry; C-WORKFLOW adds the optional constructor
  • C-MANIFEST: a manifest may declare [variant.] tables; a run that names one reads its top-level tables in place of the base's, and every variant is validated on load
  • C-COMMANDS: measure_cmd, world hooks, and workflow command and evaluate tasks get PATH, HOME, LANG, TMPDIR, the engine's own variables for them, and what [judge].env, [world].env, or [workflow].env declares; a declared variable the engine lacks refuses the run
  • C-CAPABILITY-DISCLOSURE: each variable a table declares for host-side commands is disclosed with its table
  • C-WIRE: task_started { task, attempt, estimate_usd } is an additive event kind a playbook writes before each attempt
  • options are compile-time except a dynamic choice's, which a dependency supplies (C-ROUTE)
  • C-COMMANDS: a declared NAME? is optional, passed to the command when the engine sets it and left out otherwise; only a required name the engine lacks refuses the run
  • C-CAPABILITY-DISCLOSURE: an optional declared variable is disclosed with its table and marked optional
  • C-COMMANDS: a manifest's top-level edition decides what host commands receive; edition 1, the default, inherits the engine's environment, warns, and refuses an env list at parse; edition 2 is the declared allowlist; an edition the engine does not support fails the parse
  • C-CAPABILITY-DISCLOSURE: under edition 1, each table whose host commands run is disclosed as holding the engine's whole environment
  • C-COMMANDS: the edition-2 base adds the run's identity, CRUCIBLE_RUN_NAME, CRUCIBLE_RUN_DISPLAY_NAME, and CRUCIBLE_UI_BASE_URL; the pod token and the controller URL reach a command only by declaration

v0.1.0 (2026-08-19)

Initial draft