Skip to main content

Run-state locus: batch vs. change-local

One step's run state — the append-only journal and the parked state — lives at a locus. A batch step keeps it under .ratchet/batches/<batch>/run/; a standalone change step driven with no manifest keeps it change-locally under .ratchet/changes/<change>/.run/. The locus selects the directory only; the journal and state file shapes are identical either way.

Defined in src/core/batch/journal.ts and src/core/batch/engine/run-state.ts.

RunLocus

type RunLocus = { batch: string } | { change: string };
LocusRun directory
{ batch }.ratchet/batches/<batch>/run/
{ change }.ratchet/changes/<change>/.run/

Files at a locus

FileContents
journal.jsonlAppend-only log of agent reports (progress, blocker, needs-input, completion), user answers/feedback, and phase proof-of-work verdicts.
state.jsonCurrent parked steps (blocked / awaiting-approval) for resume.

A JournalEntry has a kind of progress, blocker, needs-input, completion, answer, reject, proof-of-work, or proof-of-work-invalidated. A proof-of-work entry additionally carries a proof field holding the recorded verdict; a proof-of-work-invalidated entry is an append-only marker (carrying only the phase, via its key) that supersedes the latest recorded proof for that phase so the boundary re-runs.

Locus-aware functions

function runDirForLocus(projectRoot: string, locus: RunLocus): string;
function journalPathForLocus(projectRoot: string, locus: RunLocus): string;

function appendJournalForLocus(
projectRoot: string,
locus: RunLocus,
entry: Omit<JournalEntry, 'at'> & { at?: string }
): JournalEntry;

function readJournalTolerantForLocus(
projectRoot: string,
locus: RunLocus
): JournalEntry[];

function readChangeJournalTolerantForLocus(
projectRoot: string,
locus: RunLocus,
change: string
): JournalEntry[];

The batch-named helpers (appendJournal(projectRoot, batch, …), readJournalTolerant, readChangeJournalTolerant, …) delegate to the *ForLocus functions with a { batch } locus, so existing batch callers keep their signatures and behavior.

Proof-of-work records

A phase's proof-of-work verdict is journaled durably at the phase boundary by batch apply (see Phase gates and proof-of-work), so the verdict survives across the stateless single-step apply invocations. The recorded verdict drives the next phase's gate: computeBatchStatus blocks the following phase when a phase's recorded hard-gate proof failed (gatePassed: false), and opens it once the recording passes. The record is a proof-of-work journal entry whose proof field holds a ProofOfWorkRecord:

interface ProofOfWorkRecord {
phase: string; // the phase whose proof-of-work ran
passed: boolean; // the proof command/judge passed
gatePassed: boolean; // policy lets the phase complete (passed, or policy is warn)
policy: ProofOfWorkPolicy; // 'hard-gate' | 'warn'
reason: string; // machine-readable pass/fail reason
detail: string; // human-readable explanation of the verdict
}

The entry is keyed (its change field) by proofOfWorkJournalKey(phase), which returns proof-of-work:<phase> — distinct from a decomposition entry's key (the bare phase name) so the two never collide.

function proofOfWorkJournalKey(phase: string): string; // `proof-of-work:${phase}`

// Writer: append a phase's verdict to the batch run journal. IDEMPOTENT per
// boundary: if the phase already has a current (un-invalidated) record, the call
// is a no-op and returns `undefined`. The boundary proof runs outside the batch
// lock, so two concurrent applies could each reach the recorder for the same
// boundary; this guard preserves "at most once per boundary". A fresh record is
// accepted again only after a `proof-of-work-invalidated` marker (the
// `batch rerun-proof` path) drops the phase from the fold.
function recordProofOfWork(
projectRoot: string,
batch: string,
phase: string,
record: ProofOfWorkRecord
): JournalEntry | undefined;

// Writer: append a superseding invalidation marker for a phase (append-only;
// the original proof-of-work entry is left in place). Backs `batch rerun-proof`.
function recordProofOfWorkInvalidation(
projectRoot: string,
batch: string,
phase: string
): JournalEntry;

// Pure reader: fold any journal entry list to the latest record per phase.
// A `proof-of-work-invalidated` marker deletes its phase from the map; a later
// real `proof-of-work` record for the same phase re-adds it.
function proofRecordsFromEntries(
entries: JournalEntry[]
): Map<string, ProofOfWorkRecord>;

// Readers: latest-wins, derived from the append-only (oldest-first) journal.
function readProofOfWorkByPhase(
projectRoot: string,
batch: string
): Map<string, ProofOfWorkRecord>;

function readLatestProofOfWork(
projectRoot: string,
batch: string,
phase: string
): ProofOfWorkRecord | undefined;

proofRecordsFromEntries folds a journal entry list to the latest record per phase (latest append wins; non-proof entries ignored). readProofOfWorkByPhase delegates to it over the full run journal, and computeBatchStatus calls it over the same journal it already receives — so the phase gate derives from those entries with no extra disk read, and the on-disk and in-memory derivations stay identical. readLatestProofOfWork returns the latest record for one phase, or undefined when none has been recorded. batch apply builds its "already recorded" phase set from readProofOfWorkByPhase so the boundary proof runs at most once per boundary, and reads the same records to cite a failing proof that is holding a later phase shut.

Superseding a recorded proof (re-run)

A recorded verdict is durable, but it can be superseded without rewriting the append-only journal. The ratchet batch rerun-proof [name] --phase <phase> verb (see commands: batch rerun-proof) calls recordProofOfWorkInvalidation, which appends a proof-of-work-invalidated marker keyed by the same proof-of-work:<phase> key. Because the fold is order-sensitive — proof-of-work adds a phase, the marker deletes it, and a newer proof-of-work re-adds it — the phase simply disappears from the current record map after a marker. Both consumers therefore re-open by construction: computeBatchStatus sees no recorded verdict for the phase (gate re-opens) and pickNextStep re-includes the phase's boundary proof-of-work step (it is no longer in the "already recorded" set), so the next batch apply re-runs the phase's configured proofOfWork.run and records a fresh verdict. The original proof-of-work entry is never deleted — the audit trail of the prior verdict is preserved. When no proof is recorded for the phase, the verb is a no-op and appends nothing.

Blackbox proof of the gate

test/e2e/proof-of-work-gate.sh is the end-to-end proof that this gate is real, not merely modeled. It builds the package and drives the BUILT CLI as a child process (node bin/ratchet.js batch apply|status … --json) against the committed two-phase fixture test/e2e/fixtures/proof-of-work-gate/batch.yaml in fresh scratch project roots. The fixture's phase-1 proof-of-work passes or fails purely on the RATCHET_E2E_PROOF environment variable the script sets — a neutral shell test, so no agent is ever spawned — and the script asserts on the --json output of batch apply and batch status that a failing hard-gate proof blocks entry into phase 2 with a report naming the failing proof, that a passing proof opens the gate (next step points at phase 2's change), and that warn advances while surfacing the failure (gatePassed: true, a apply line). It writes a fail-closed machine-readable result to test/e2e/.results/proof-of-work-gate.json and exits 0 only when every scenario holds.

Engine behavior

runChangeStep resolves the locus from ChangeStepContext.batch: a { batch } locus when set, else { change }. With no batch it also omits the RATCHET_BATCH_NAME environment variable, so the runtime places its temp prompt file under the change-local .run/ rather than a batch run directory. See Change-step core.