Fulfill Commands
rocky fulfill is the loop half of spec fulfillment (EXPERIMENTAL). The product verbs parse, verify, lower, and approve — deterministically. rocky fulfill drives them: it dispatches an untrusted drafting agent, re-verifies everything the agent touched, hands the result to the engine’s governed propose, waits for the approval marker your rocky review --approve writes, and applies with the digest gate. One invocation advances as far as it can without you, then stops and tells you the exact next command.
rocky fulfill <product> │ ├─ no spec yet ──▶ agent drafts a candidate ──▶ you: rocky fulfill approve-spec <product> ├─ spec approved ─▶ verify posture ─▶ lower contract ─▶ agent drafts SQL │ (worker profile: read + compile/test + draft_model only) ├─ runner re-verifies from disk ─▶ governed propose ─▶ you: rocky review <plan> --approve ├─ digest-gated apply ─▶ observe (tests + freshness + the declared data checks) └─ a declared check FAILS on the live table ─▶ observed_failing ─▶ repair round ─▶ a NEW plan ─▶ you approve againEvery stop prints the state, why the loop stopped, and the next command. Exit codes: 0 clean stop (including a waiting ask and observing) · 2 blocked · 3 parked at applying_unknown for a human · 4 observed_failing — the plan applied, and the applied output is failing a check the product declared about itself. 1 stays the generic command-error code, so a script can tell a parked receipt from a crashed command.
rocky fulfill <product>
Section titled “rocky fulfill <product>”Advance the product’s state machine.
rocky fulfill revenue_dailyThe loop trusts nothing it did not verify itself:
- The drafting agent runs in its own process group on a worker-profile MCP server. That server offers it read, compile, test, and
draft_model. It offers no tool for contracts, metadata, data checks, proposals, reviews, or schedules. That is a closed tool route, not a limit on the agent. The agent is an ordinary process in your project directory, so one that can write files can write those files directly. The later gates are what catch that, not the tool list. The agent’s lastdraft_modelcall is its hand-off: the tool mirrors what it wrote into the task outbox, and when the agent is gone the runner commitsmodels/<model>.sqlandmodels/<model>.tomlonly if they still match that hand-off byte for byte. Anything else that edits those two files inside the drafting window — a person, a second tool — makes the round refuse with the file named, rather than committing bytes nobody handed off. The whole group is killed when the task ends, so helpers and accidental stragglers do not outlive the task. A process that puts itself in a new session withsetsidleaves the group and is beyond any process-group kill — that is part of the hostile-local-agent residual below, not a covered case. - After drafting, every spec-owned artifact is byte-verified against the committed lowering manifest. Drift means
blocked— the loop names the tampered file. The manifest covers the files the lowering emits: the contract and the model sidecar. It does not covermodels/test_definitions.toml, which a[[use_test]]entry resolves against. The check-set comparison described below covers that. - The plan reaches the review queue only through the engine’s one governed propose path, as the
agentprincipal, under your[policy]. - The apply recomputes the spec digest from the approved snapshot and passes
--expect-spec-digest. The engine refuses a mismatch even if the loop did not. - Only a
Succeededoutcome is ever journaled as applied. An apply deflected as already-running keeps waiting. A resumed crash asks the idempotency store for an authoritative receipt; a backend that cannot answer leaves the state for a human, never a blind retry.
--retry re-enters a blocked product after you fix the printed remedy.
When the applied output is wrong
Section titled “When the applied output is wrong”The checks your spec declares about its output — the grain, the checks list,
the non-null columns — need a table to run against. They cannot run before the
apply, so the loop reports them deferred at verify and runs them at observation,
where the table finally exists.
A check that FAILS there is different from every other failure the loop handles: the wrong data is already live. The loop does not roll it back. It does this instead:
- Records
observed_failing— never a healthy-lookingobserving— and prints which check failed and what it measured. - On your next run, reads the checks AGAIN. A reading that has gone green releases the product; nothing trusts the stored verdict.
- If it is still failing, spends one repair round: the drafting agent gets the failing check and its actual values, and rewrites the model.
- Proposes the repaired model as a NEW plan. You review and approve it exactly like the first one. The earlier approval buys it nothing — a bare apply of the repaired plan refuses, and the plan carries a new id, so the marker you wrote for the failing plan cannot be reused.
Repair rounds are bounded. Repeated failures land blocked, naming the check
that would not go green, so a product can never cycle you forever on a defect the
agent cannot fix.
Two things report rather than route. Staleness is usually a scheduling fact, not a model defect — rewriting SQL cannot fix a job that did not run. Warning-severity checks are ones you declared as not disqualifying. Both are printed and journaled; neither starts a repair.
A check the loop cannot EVALUATE is not a check that passed. If a check errors —
its SQL will not run, the column is missing — the loop holds at applied and
says so, rather than claiming health or rewriting a model on a guess.
Before it runs any of them, the loop compares the checks on disk with the set it
verified for this plan. It compares what the model loader produces, so a
[[use_test]] entry edited in models/test_definitions.toml is covered even
though no manifest lists that file. The comparison also covers the table the
checks run against, and the warehouse is fixed at the same moment, so a later
edit to rocky.toml cannot move the query. A set that does not match is not run:
the loop holds and tells you to put back what changed.
Be exact about what that buys you. It catches a change made AFTER the plan was verified. A check that was already on disk at that point is part of the verified set, so this comparison does not question it — including one an agent wrote there. Verification pins the check set; it does not judge it. To know what will run, read the model’s sidecar.
Two invocations never fight: every state write is a compare-and-swap, and a loop that finds a live owner prints its pid and exits. A crashed owner is taken over automatically — a dead pid is detected by its start time, so a recycled pid never counts as alive.
JSON output
Section titled “JSON output”rocky fulfill <product> --output json emits one FulfillOutput document: state, message, next_command, spec_digest, and the pinned plan_id while one is in flight.
rocky fulfill approve-spec <product>
Section titled “rocky fulfill approve-spec <product>”Approve the current candidate spec. This is the same authority transition as rocky product approve — one implementation, two spellings. The snapshot file is written first, immutable and digest-addressed; then one state-store transaction records the approval, moves the loop state, and appends the journal row. A second approver racing you fails cleanly and is shown the winning digest.
Configuration
Section titled “Configuration”The loop reads one block in rocky.toml:
[fulfill]# briefs_dir = "briefs" # optional overrides for the agent task briefs
[fulfill.driver]type = "subprocess" # or "replay"command = ["claude", "-p", "{brief}"] # your agent command; {brief} is replacedenv_allow = ["ANTHROPIC_API_KEY"] # the worker sees ONLY these variablestimeout_seconds = 900kill_grace_seconds = 30Bring your own model: the command template is the whole integration. type = "replay" executes a recorded session file instead — deterministic and credential-free, which is how CI exercises the loop.
Wrapping the worker in an OS sandbox
Section titled “Wrapping the worker in an OS sandbox”You can run the agent inside an operating-system sandbox today. Put the sandbox program first in command. Rocky starts command[0] as given and does not look inside it. The only checks are that command is not empty and that exactly one argument contains {brief}. The wrapper becomes the leader of the worker’s process group, and its children stay in that group.
[fulfill.driver]type = "subprocess"command = ["/usr/bin/sandbox-exec", "-f", "/etc/rocky/agent.sb", "claude", "-p", "{brief}"]env_allow = ["ANTHROPIC_API_KEY", "PATH"]On Linux, ["/usr/bin/unshare", "--pid", "--fork", "claude", "-p", "{brief}"] has the same shape. The sandbox profile, and whether the host allows the sandbox, are yours to set up. Rocky starts the worker with an empty environment, so list PATH in env_allow if the wrapper needs it to find claude.
Rocky does not require a wrapper, and it does not check that you use one or that it works. It does not provide OS-level containment. A descendant that deliberately leaves the group, for example with setsid, can outlive the group kill unless your sandbox stops it. See #1491.
What v0 does not defend
Section titled “What v0 does not defend”The worker runs on the same machine as the runner and the review markers, and markers are unsigned. The gates defend against mistakes, prompt-injection-shaped drift, and tool misuse — not against a hostile local process acting as your user. Do not point the driver at an agent binary you do not trust. Signed approvals and OS sandboxing are named follow-up work.
Be precise about what “acting as your user” means, because it is a property of how you deploy, not of Rocky. .rocky/fulfillment/<product>/outbox/ is the one part of that directory the worker is meant to write. Elicitation writes candidate_spec.toml and, optionally, questions.json there directly. Drafting’s draft_model tool mirrors its draft there. The loop deletes and recreates outbox/ before every dispatch. A worker meant to write only that directory needs an inherited default, such as a umask or an ACL on its parent. A one-off chmod would not do: the next dispatch would erase it. The loop reads the outbox only after the worker exits and its process group is killed.
Every file the loop takes from the outbox, and every briefs_dir override, is read the same way. On Unix the read refuses a symlink at the file and a file with a second hard link. So a link planted to a file outside the project is refused. It is never copied into products/, models/, or the worker’s prompt. Crash recovery restores a .ff-prev backup the same way: it reads the backup under those rules and publishes the bytes it read through a new file, rather than renaming the backup itself into place.
The loop checks a digest on the elicitation candidate before writing products/<product>.toml. With type = "subprocess" the loop computes that digest itself, from the candidate it read after the worker exited. It records which bytes were accepted. It does not prove what the worker meant to write. With type = "replay" the digest comes from the recorded session, so there it is a real check.
The claims above hold only while these stay closed to the same principal that runs the worker:
.rocky/plans/— review markers. An unsigned marker with a matching plan id is accepted as approval.- The approved-spec snapshot,
.rocky/fulfillment/<product>/approved-<digest>.toml— the file the apply step’s digest gate reads. - The rest of
.rocky/fulfillment/<product>/exceptoutbox/— task transcripts, the committed lowering manifest, and its own staged and backup copies (phase-staging.json,lowering-manifest.json.ff-staged,lowering-manifest.json.ff-prev). The contract’s and the sidecar’s staged and backup copies (also.ff-staged/.ff-prev) sit beside them inmodels/instead, inside the repair window described below. - The state store — wherever
--state-pathpoints; see below. It holds the loop’s compare-and-swap state, its journal, and its approval record, not.rocky/fulfillment/<product>/. products/<product>.toml— the working spec the nextapprove-specsnapshots, not an approved copy.- The
briefs_diroverrides. They are trusted project configuration, likerocky.toml, and they become the worker’s instructions. rocky.toml— the runner reloads it on every invocation and spawns[fulfill.driver].commandfrom it.[policy]and[state]come from the same file. A worker that can write it runs its own command as the runner next time.
The state store must sit outside every directory the worker can write. The default store lives inside models/, at models/.rocky-state.redb, so rocky fulfill and rocky fulfill approve-spec refuse to run on it. They also refuse a namespaced store under models/.rocky-state/, or a path that resolves into models/ through .. or a symlink. The error names the fix: pass --state-path with a location the worker cannot write.
rocky --state-path .rocky/state.redb fulfill revenue_dailyPass the same --state-path to every rocky fulfill, rocky fulfill approve-spec and rocky review call for the project. Every next step the loop prints carries it, so a command run as printed reaches the store the loop reads. Commands quoted in a stop’s message carry it too. rocky fulfill approve-spec without it refuses, like the loop. rocky review and rocky product approve do not check where the store is. Without the flag they use the default store, and an approval recorded there never reaches the loop.
.rocky/state.redb is only safe if the worker cannot write .rocky/. Pick a path that fits how you deploy.
The worker’s own rocky mcp --profile worker records its draft_model policy decisions in a state store, so give it the same --state-path too. With type = "replay" the loop does this for you: it starts the worker as rocky --state-path <store> mcp --profile worker. With type = "subprocess" your agent starts the MCP server from its own configuration, so add the flag there, before mcp: "args": ["--state-path", ".rocky/state.redb", "mcp", "--profile", "worker"]. Without it the worker records into the default models/.rocky-state.redb, and those decisions are missing from the custody chain and the brief. With a remote state backend (s3, gcs, tiered) and a [policy] block, the worker never writes the shared ledger. It records each draft decision in the local store only. The loop’s propose reads those rows before it downloads remote state, then publishes them with its own decision in one compare-and-swap step. So the worker needs read-only state credentials at most (to list freeze markers), and it never gets write access to freezes or budgets. propose publishes only rows with the shape of a draft decision (draft: and the other draft prefixes, an agent propose evaluation of a compiled model). It adds them after its own verdict, and never replaces a row the shared ledger already holds. A forged row in the worker’s file cannot change the propose verdict.
A worker that can also write anything in the closed list above is the hostile-local-process case, and nothing here defends against it. If your worker and your runner share a user, treat that as the concession it is.
Two limits have their own tracking issues. A descendant that puts itself in a new session with setsid leaves the process group, is re-parented by the operating system, and survives the group kill; OS-level sandboxing is the fix (#1491). Rocky opens committed files with O_NOFOLLOW and creates them with O_EXCL, but it does not use directory-relative system calls, so a directory component swapped between the check and the open stays a window. O_NOFOLLOW is a Unix flag, and so is the hard-link check. On Windows the backup, outbox, and brief-override reads follow a link and do not count hard links.
The full boundary, and how it applies to any agent rather than just this loop, is set out in Operating Rocky with agents, “What the three gates do not defend against”.
Repair rounds
Section titled “Repair rounds”A red verification sends the loop back to the agent for a repair round. The repair rewrites the merged sidecar file, which is exactly what it is meant to do. If the next verification is green the loop carries on to propose. A repair is not guaranteed to work: one that leaves the verification red is retried up to the repair budget, and a product that exhausts that budget stops at blocked with the last failure printed. What no longer happens is the loop reporting its own repair as tampering.
The loop authorizes that write rather than assuming it. Before it dispatches a repair worker it re-checks every hash the committed manifest records. Drift there had no authorized writer, so it is tamper: the product moves to blocked and nothing is rewritten. Only when every file verifies does the loop demote the manifest to its contract-only phase, which returns the sidecar to the writable set. The next merge re-records the hashes from what it merged. Hashes are only ever written by the commit protocol.
Two consequences worth knowing:
- The window is real. Between the repair dispatch and the merge that closes it, the sidecar is not covered by any hash, and the merge preserves keys and
[[tests]]entries the lowering does not own. Content added to that file during the window survives into the committed artifact. Using it needs a process that can write your models directory — the same access that can forge an approval marker. Trusted handling of the repair agent’s output bytes is tracked in #1515. - Do not run
rocky product compileagainst a product a loop is driving. Compiling mid-window would re-merge the previous round’s sidecar, so the command refuses whenever the fulfillment record still carries an owner or worker-group stamp. Stop the loop, or let it reach its next stop, and the stamp is cleared. A loop that was killed outright leaves its stamp behind;rocky fulfill <product>takes that record over by checking the owner’s start time, and the refusal says so.
Taking an already-merged generation back to its contract-only phase is a different act from lowering a fresh one, and only the loop can do it. rocky product compile lowers a first contract for a product that has no committed manifest yet, which is ordinary. Nothing but rocky fulfill <product> demotes a merged generation: there is no verb for it, and the engine refuses any caller that does not hold the loop’s own compare-and-swapped record.