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.
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. The claims above hold only while these are not writable by the same principal that runs the worker: .rocky/plans/ (review markers — an unsigned marker with a matching plan id is accepted as approval), .rocky/fulfillment/<product>/ (the task outbox and the loop’s journal), and products/ (approved specs). A worker that can write models/ but not those directories is the shape the gates are built for. A worker that can write all four 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; on Windows one backup read follows a link.
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.