← All guides

Guide

Close the loop without a deploy

The finalise gate wants a running app, not a cloud bill. How to close the five-stage loop on your own machine, and what the parity assertion you sign means.

Kick the tyres by hand ends with FBS-001 at complete, one stage short of the end of the five-stage build cycle. Try to take the last step and you hit a wall:

$ rcf build finalise FBS-001
[error] usage finalise: --url <deploy-url> is required

The wall is there on purpose. Only the finalise gate can write verified, and verified means independently verified against a running app, so the gate refuses to run without one. What the flag name does not tell you is that the URL does not have to be a deployment. A server on localhost closes the loop too, provided you say honestly what it is. This page walks that path on the same throwaway plant-log project, first the refusal, then the pass.

What you need

  • The plant-log project from Kick the tyres by hand, with FBS-001 at complete. If you deleted it, the rebuild is five minutes of that page.
  • Claude Code installed. The gate launches a fresh verifier agent, Claude Code by default, in its own isolated session with its own browser tooling.
  • A few minutes per run. The verifier is an agent genuinely using your app, not a script pinging it.

Give the gate something to verify

The chain's one acceptance criterion is AC-101-1, "Recording a plant with a name and an interval succeeds". 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) => (
  { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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'));

Start it in a second terminal and leave it running:

node server.js

Run the gate

Back in the project directory:

rcf build finalise FBS-001 --url http://localhost:3000 --profile local-dev --parity-env

Two flags carry the weight:

  • --profile local-dev names the runtime honestly. The verify report is stamped with the profile it ran against, so a local run can never masquerade as a deployed one.
  • --parity-env is your assertion that this runtime is a faithful stand-in for production. It is the only route to ship authority off the deployed profile: without it, a local-dev or ci run that passes is a correctness pass that holds the item at complete rather than promoting it. On this toy the local server is the only runtime the project has, so the assertion is trivially yours to make. On a real product it is a claim you are accountable for.

The gate spawns rcf verify run as a fresh subprocess: a cold verifier 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 verdict:

$ rcf build finalise FBS-001 --url http://localhost:3000 --profile local-dev --parity-env
[finalise] launching rcf verify run (fresh subprocess) against http://localhost:3000 [profile=local-dev]...
[rcf-verify] verdict DEGRADED [ship] -> .rcf-verify-report.json
[finalise] gate passed; marked FBS-001 complete -> verified. Report: .rcf-verify-report.json

Exit 0, and the status only this gate can write is written:

$ rcf build queue
| order | tier | id | title | status | state | blocked by |
|---|---|---|---|---|---|---|
| 1 | 0 | FBS-001 | Record a plant end to end | verified | verified |  |

Note the verdict was DEGRADED, not a spotless pass. The verifier posted a plant name a megabyte long, the server accepted it, and the page bloated to match; that went in the report as a DEGRADED finding with a screenshot. The default severity gate blocks at BROKEN, so a DEGRADED finding rides along in the report rather than stopping the ship. If you want the gate to refuse on that too, tighten it: --severity-gate DEGRADED. Either way the finding is on the record in .rcf-verify-report.json, in the project root, waiting in the chain's history rather than in someone's memory.

When the gate says no

Worth knowing what refusal looks like, because this page's own server failed the gate 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:

$ rcf build finalise FBS-001 --url http://localhost:3000 --profile local-dev --parity-env
[finalise] launching rcf verify run (fresh subprocess) against http://localhost:3000 [profile=local-dev]...
[rcf-verify] verdict BROKEN [ship] -> .rcf-verify-report.json
[finalise] gate NOT passed (rcf verify exit 5); FBS-001 left 'complete'.
verdict: BROKEN [ship]
runtime: profile=local-dev url=http://localhost:3000 parity-env
findings (1):
  - BROKEN AC-101-1 (Record a plant)

Exit 4, the refused code: the item stays complete, the finding names the acceptance criterion it broke against, and the report carries the detail.

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 in the report. The acceptance criterion never mentions escaping; the verifier holds the app to a security floor anyway.

A ten-minute toy, and the independent verifier found a real crash and a real injection its author had not. Fix the app, re-run the gate. That is the loop working, not the loop failing.

What the parity assertion is for

On a real project, the deployed profile against the live runtime is still the run the method is written around, and what the referee guarantees is stated in those terms. --parity-env exists for the honest middle ground: CI pipelines and pre-deploy checks where the runtime genuinely mirrors production and someone is prepared to say so on the record. The report logs the assertion alongside the profile, so an auditor reading the chain later sees exactly what kind of run wrote verified, and on whose claim. Use it to close the loop locally; do not use it to avoid ever standing the real thing up.