Guides
Close the loop without a deploy
Finalise ingests the verifier's Test-stage report on the pull-request branch, before the merge, and never runs verify itself. A local server can carry an item to verified when the report names the engine, the port and the commit it served.
Kick the tyres by hand ends with FBS-001 at complete, one stage short of the end of the five-stage build cycle. Running finalise on it produces this refusal:
$ rcf build finalise FBS-001
[error] refused finalise: ci evidence missing: pass --ci-run <ref>
Finalise takes no URL: --url exits 2, the usage code, with a line that points to rcf verify run --fbs FBS-001 --hand verifier at the Test stage.
The wall is there on purpose. verified on the normal lifecycle path means an independent verifier checked the work, and that check belongs to the Test stage, by its own hand. The verifier runs rcf verify run --fbs <fbs-id> --hand verifier and commits the report it writes on the pull-request branch. Finalise runs on that same branch, before the merge: it reads the report, checks where it came from, and names the CI run that passed; it never runs verify itself. The verified status it writes is committed on the branch, and the merge carries it. A sanctioned manual override, rcf define update FBS-001 --set executionStatus=verified, can write the status without any of this.
A local server counts. Finalise accepts a report against a loopback address such as localhost when the report names the engine that served the app, the port, and the commit it served, and that commit is the one the report verified. Without a production deploy, three targets can carry an item to verified: a local server, a preview host that serves the reviewed head, and the packed package itself. This page walks the local route on the same throwaway plant-log project, then the other two.
What you need
- The plant-log project from Kick the tyres by hand, with
FBS-001atcomplete. If you deleted it, the rebuild is five minutes of that page. - The project in a git repository, with everything committed. Any branch will do: finalise runs on the pull-request branch, before the merge. The verifier refuses a working tree with uncommitted changes, because the report is stamped with the head it verified, and finalise reads that provenance from git.
- Claude Code installed, for a run against a URL. That run launches a fresh verifier agent, Claude Code by default, in its own isolated session with its own browser tooling and the feedback ask off, so your project's feedback hook stays quiet inside it. The package route runs no agent.
- A few minutes per URL run. The verifier is an agent genuinely using your app, not a script pinging it.
Give the verifier something to verify
The chain's one acceptance criterion is AC-101-1, "Recording a plant with a name and an interval succeeds". Add the Given, When, Then fields that the verifier uses to compose its acceptance contract:
rcf define update US-101 --set acceptanceCriteria.0.given="the Plant log form is open" --set acceptanceCriteria.0.when="the user records a plant with a name and an integer watering interval" --set acceptanceCriteria.0.then="the stored plant and interval appear in the list"
So far nothing implements it. This server is the smallest app that does; save it as server.js in the project directory:
// Plant log: the smallest app that satisfies AC-101-1
// ("Recording a plant with a name and an interval succeeds").
import { createServer } from 'node:http';
const plants = [];
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => (
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
));
const page = (body) => `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Plant log</title></head>
<body>
<h1>Plant log</h1>
<form method="post" action="/plants">
<label>Name <input name="name" required></label>
<label>Watering interval (days) <input name="intervalDays" type="number" min="1" required></label>
<button type="submit">Record plant</button>
</form>
${body}
</body>
</html>`;
const list = () => plants.length === 0
? '<p>No plants recorded yet.</p>'
: `<ul>${plants.map((p) => `<li>${esc(p.name)}: water every ${p.intervalDays} day(s), next due ${p.nextDue}</li>`).join('')}</ul>`;
createServer((req, res) => {
if (req.method === 'GET' && req.url === '/') {
res.writeHead(200, { 'content-type': 'text/html' });
res.end(page(list()));
return;
}
if (req.method === 'POST' && req.url === '/plants') {
let raw = '';
req.on('data', (c) => { raw += c; });
req.on('end', () => {
const form = new URLSearchParams(raw);
const name = (form.get('name') ?? '').trim();
const intervalDays = Number(form.get('intervalDays'));
if (!name || !Number.isInteger(intervalDays) || intervalDays < 1 || intervalDays > 3650) {
res.writeHead(400, { 'content-type': 'text/html' });
res.end(page('<p>A plant needs a name and a whole number of days.</p>'));
return;
}
const nextDue = new Date(Date.now() + intervalDays * 86400000).toISOString().slice(0, 10);
plants.push({ name, intervalDays, nextDue });
res.writeHead(303, { location: '/' });
res.end();
});
return;
}
res.writeHead(404, { 'content-type': 'text/html' });
res.end(page('<p>Not found.</p>'));
}).listen(3000, () => console.log('Plant log on http://localhost:3000'));
Commit both changes, then start the server in a second terminal and leave it running:
node server.js
Verify against the local server
Back in the project directory, turn to your agent:
The build item is done and committed. Run the verifier against my local server at <url> as the Test-stage hand, naming the engine that serves it and the commit it serves.rcf verify run --repo . --fbs <fbs-id> --hand verifier --verifier-id <your-verifier> --profile local-dev --url <url> --parity-env local --engine "<engine>" --served-sha <full-head-sha>A Test-stage run against a local server. The run writes a verify report under rcf/verify-reports/ for the committed head and records the engine, the port and the served sha; a loopback --url without --engine and --served-sha exits 2. With a PASS verdict and ship authority, finalise can promote from this report on the pull-request branch, before the merge.
The flags carry the weight:
--fbs FBS-001 --hand verifiermakes this a Test-stage run for one build item. The report goes torcf/verify-reports/FBS-001/<head12>.json, named after the commit it verified, and--verifier-idrecords who ran it. Finalise later checks that no builder commit carries the same identity.--profile local-devdeclares the runtime profile. The report records that operator-declared profile; truthful classification remains your responsibility.--parity-env localis your assertion that this runtime, namedlocal, is a faithful stand-in for production. Off thedeployedprofile it is the only route to ship authority. This toy has no production runtime to compare, so the assertion is a demo-only override here; on a real product it is a claim you are accountable for.--engineand--served-shasay what served the app and which commit it served. Here that is--engine "node server"and--served-sha "$(git rev-parse HEAD)", the full sha of the committed head. The report records them astarget.engine,target.port(3000, taken from the URL) andtarget.servedSha, and they are what lets finalise accept a loopback report.
Both are required with --hand and a loopback URL. Leave one off and the run stops before it sends a single probe or writes a report:
$ rcf verify run --repo . --fbs FBS-001 --hand verifier --verifier-id <your-verifier> --profile local-dev --url http://localhost:3000 --parity-env local
[error] usage --engine <name> is required with --hand and a loopback --url
That is exit 2, the usage code. A served sha that is not the full sha of the committed head is refused with exit 4 and verify report refused: --served-sha <value> is not the committed HEAD <sha7>, again with no report written.
The verifier is a cold agent whose only inputs are the chain (the acceptance contract) and the URL. It has none of your build context, and it drives the app through a real browser. A few minutes later the run ends with one line naming the report, the verdict and how many criteria were verified out of the total in scope:
verify report: rcf/verify-reports/FBS-001/<head12>.json <verdict> <verified>/<total>
Read the report. Every finding names the acceptance criterion it was raised against, with reproduction steps and a screenshot. The default severity gate blocks at BROKEN, so a DEGRADED finding rides along in the report rather than stopping the run; --severity-gate DEGRADED tightens it.
A verdict short of PASS with ship authority stops there: finalise refuses it with exit 4, the refused code, and FBS-001 stays at complete. The run still did its job: you know what the verifier thinks of the app, and nothing claims more than that.
When the verifier says no
Worth knowing what a failing run looks like, because this page's own server failed the verifier twice before it passed. The first draft did not clamp the watering interval; the verifier, probing the form adversarially, posted a value large enough to overflow the date arithmetic and crash the process mid-run. It reported BROKEN against AC-101-1.
The second draft fixed the crash and failed again. It rendered plant names into the page unescaped, so the verifier recorded a plant named <img src=x onerror=alert(1)>, reloaded, and watched its own alert fire in the browser. Stored XSS, filed as BROKEN against the same criterion, with reproduction steps and a screenshot. Those runs went beyond the criterion's explicit wording, but that is not guaranteed. The verifier is prompted to probe edge inputs and the security or quality floors the criterion promises.
A ten-minute toy, and the independent verifier found a real crash and a real injection its author had not. Fix the app, commit, run the verifier again; each head gets its own report. That is the loop working, not the loop failing.
Finalise on the branch
Commit the report the run wrote, on its own, with the trailer Hand: verifier. Finalise reads only a report committed at HEAD. Run it before that commit and it says so, with exit 4:
$ rcf build finalise FBS-001 --ci-run <ref>
[error] finalise refused: verify report rcf/verify-reports/FBS-001/<head12>.json is not committed at HEAD
With the report committed, a PASS verdict with ship authority and CI green on that head, finalise the item on the branch, before the merge:
rcf build finalise FBS-001 --ci-run <ref>
--ci-run names the CI run that passed on the pull-request head the report names; finalise records it on the verified line and never fetches it. Finalise reads the newest report under rcf/verify-reports/FBS-001/ (or the one --report <path> names) and promotes only when every check holds: FBS-001 at complete with a Code Node on every bound criterion; the report committed at HEAD with its bytes on disk unchanged, inside the project root and not read through a symbolic link; the report's head contained in HEAD; for a loopback target, the engine, the port and a served sha equal to the report's head; the verifier hand separate from the builder; a report no older than --max-report-age days (7 by default); and a PASS verdict with ship authority. Exit 0 writes verified on the branch.
Commit that write and merge as your workflow normally does: the merge carries verified to the default branch. Once the work lands with a merge commit, running finalise again on the default branch confirms the status and writes nothing. A squash merge is not covered by that promise.
Another target: a preview host
A preview host is any address that serves the reviewed head: a pull-request preview, a version URL from your hosting platform, a staging box. It is not production, which is the point. Push the branch, let your pipeline serve it, and run the same command against that URL, with the profile that honestly describes it and --parity-env <env> only if you are prepared to say it matches production. A target that is not loopback does not need --engine or --served-sha. From there the route is the same: commit the report with Hand: verifier, then finalise on the branch before the merge.
Another target: the packed package
When the thing you build is a package, a library or a CLI, there may be no app to serve at all. Package target mode verifies the bytes you would publish instead:
rcf verify run --package . --fbs FBS-001 --hand verifier --verifier-id <your-verifier>
--package . exports the committed HEAD to a scratch directory, packs it there with npm pack, installs the tarball in a scratch project, and runs the test files that the build item's test cases point at with node --test against the installed package. Pass --artefact <tgz> instead to verify a tarball you already packed, and add --offline to install from the local npm cache only. Each criterion is recorded as pass, fail, skipped, or critique-only when nothing is bound to it; the raw test output and the install log land beside the report. The mode is deterministic and runs no agent, so it needs test cases with real test pointers rather than a browser. The plant-log toy has neither a package nor a bound test, so this route fits your own library work rather than this page's server.
--engine and --served-sha do not apply here: with --artefact or --package and no --url, either flag exits 2 before anything is packed or installed. From there the route is the same as the others: commit the report with Hand: verifier, then rcf build finalise FBS-001 --ci-run <ref> on the branch with the item at complete, before the merge.
What the parity assertion is for
On a real project, the deployed profile against the live runtime is the run the method is written around, and what the referee guarantees is stated in those terms. --parity-env <env> exists for the honest middle ground: CI pipelines and preview hosts where the runtime genuinely mirrors production and someone is prepared to say so on the record. The report logs the named environment alongside the profile, so an auditor sees exactly what kind of run wrote verified. It does not record the identity of the person who made the assertion. On the local route that assertion is what gives the run ship authority, so a local run that writes verified rests on it. Make it only when it is true, and do not use the local route to avoid ever standing the real thing up.