Docs · Understand · Evidence & integrity
Evidence & integrity.
runward frames the delivery an agent produces; it does not write the code. This page covers how the gate proves that the decisions behind that code are traced to something real: typed evidence pointers that must resolve to non-empty content, a rule-conformance manifest that accounts for every high-impact rule, a SHA-256 seal that reddens when sealed evidence drifts, and the advisory behavioral/redone-task proofs that sit above the gate. Every check here is deterministic and runs with zero LLM and zero network: it reads bytes and compares them, it never executes your code and never asks a model for a verdict.
The evidence layer is a short chain: a typed pointer resolves to real content, then the whole thing is sealed.
The rule-conformance manifest
Each gated deliverable carries a ## Rule conformance markdown table. The gated (phase → deliverable) pairs are fixed:
architecture.md(architect)execution-topology.md(topology)floor.md(floor)governance/threat-model.md(govern)handover.md(handover)
Every CRITICAL or HIGH craft rule mapped to a phase must appear as one row, in one of three accounting states:
applied— needs an evidence pointer. An applied row with an empty Evidence cell is a violation.deviated— needs an ADR that actually exists inrunward/adr/; otherwise it fails.n/a— needs a real one-line reason: shorter than 8 characters or a[bracketed placeholder]is rejected as trivial.
How the expected set is derived
The expected set is derived from the rule files themselves: a rule is expected at a phase when its frontmatter says impact: CRITICAL or HIGH and its phases: [...] includes that phase. The gate reads the mission's own runward/rules/ when present, else the packaged rules.
conformance() then flags:
- a rule not accounted for at all
- a status that is unset or invalid
- an unknown rule slug — with a migration hint if it was renamed/removed
- a rule listed more than once
The non-vacuity floor
There is also a non-vacuity floor: if the number of CRITICAL/HIGH rules mapped to a phase drops below a pinned minimum, the gate raises a (mapping) violation — you cannot quietly strip the mapping to make the gate pass.
Scaffolding the manifest form (never the decision)
runward manifest gives a read-only overview; runward manifest --sync scaffolds only the form:
- it appends missing expected rows with an empty status
- it rewrites renamed slugs per the migration record
- it creates a missing section
It never sets a status, never writes evidence, and never deletes a row (it only reports duplicates, unknown slugs, and removed slugs for the operator to fix by hand). A scaffolded row has an empty status, and the gate refuses empty-status rows until the operator decides. manifest exit codes: 0 = done, 2 = no mission.
Typed evidence pointers
An applied row's Evidence cell can carry typed pointers, opt-in per row, several per cell separated by ;, with surrounding prose left untouched. There are exactly three prefixes:
file:PATH[:LINE][#SYMBOL]— a project file, optionally a line number and a#symbolsubstring.test:PATH[::NAME]— a test file, optionally a::testName.adr:NNNN— an ADR id.
What the gate checks for file: and test:
For file: and test: pointers the gate checks, deterministically:
- Resolves — the path must resolve within one of three project bases (mission parent, mission dir, deliverable's dir). An absolute path, or a
../that climbs out of every base, is rejected rather than resolved — that is not "evidence in your project" (the containment is a normalized-prefix test with a separator so/a/project-evilnever counts as under/a/project). Failure →typed pointer does not resolve. - Is a regular file, not a directory.
- Is non-empty — a file with no non-whitespace content fails with
an empty file is not evidence. - Has the line — if
:LINEis given and the file has fewer lines, it fails. - Contains the symbol — if
#SYMBOLis given and the substring is absent:symbol "…" not found in the file (moved or renamed? update the pointer). - Contains the test name — for
test:…::NAME, the name must appear in the file.
adr: pointers
For adr:NNNN, an ADR with that id must exist in runward/adr/; the id match is anchored on a digit boundary so adr:1 is not satisfied by ADR-10.
Bare-path non-vacuity
Beyond typed pointers, every applied row is also checked for non-vacuity on any bare file-path token it mentions (recognized by known code/doc extensions): if such a token resolves to a real file, that file must be non-empty.
Evidence signatures
A rule file may declare a signature: regex in its frontmatter. When a rule is signed, its applied evidence must point at a file whose content matches that regex — this catches "cited, not applied": a pointer aimed at a file that does not actually carry the rule's shape. If no pointed file is present, or none matches /sig/i, the row fails.
Two guards protect the gate itself:
- an unsafe signature with nested quantifiers (e.g.
(a+)+) that risks catastrophic-backtracking DoS is rejected up front — a conservative screen, not a promise to catch every pathological regex; - an invalid regex in the rule file is reported rather than thrown.
Drift
driftReport handles rows that use bare prose path references (not typed pointers): if an applied row names file-path tokens and none of them resolve, that is drift and it fails. Drift is blocking under --strict. Rows that carry typed pointers are diagnosed by the evidence layer instead — one diagnosis per row, never two.
Sealing: check --freeze
runward check --freeze implies --strict. Freeze refuses to seal a red gate — if there are any deliverable gaps, conformance gaps, or failed hooks, it prints "refusing to seal a red gate" and writes nothing. On a fully green gate it hashes evidence into runward/evidence-lock.json.
What gets sealed
collectSealableEvidence walks every applied row across all gated manifests, collects each resolvable evidence file (from both typed pointers and bare path tokens), and records a SHA-256 of its bytes, keyed by project-root-relative path, in sorted order. The lock is { version: 1, sealedAt, files }, rendered with stable ordering so it is byte-idempotent on unchanged evidence. Set RUNWARD_DRY_RUN=1 to preview the seal without writing.
Verification is opt-in
Verification is opt-in by construction: no lock file means no seal check (verifyEvidenceLock returns present: false). When a lock exists, on every subsequent check --strict each sealed file is re-hashed and compared. Three ways a seal reddens the gate:
- Changed —
sha256(file) !== hash→sealed evidence changed: … — re-read the pointer, confirm the evidence still holds, then re-seal with runward check --freeze. - Missing — the sealed file is gone or is no longer a regular file.
- Escaping path — a forged lock whose key is absolute or
../-escapes the project is rejected before any read, so the verifier can never be turned into an arbitrary-file-read or/dev/zeroDoS oracle. Invalid JSON in the lock is also reported, not thrown.
The freeze subtlety
One deliberate subtlety: under --freeze the old seal is not verified, only replaced — otherwise a legitimately changed sealed file would make re-sealing impossible, since the seal violation would redden the very gate freeze requires green.
Why a seal at all (and why opt-in)
Drift and pointer checks catch a pointer that stops resolving; they do not catch a pointer that still resolves to a file whose contents silently changed after you crossed. The SHA-256 seal closes that gap — the gate greens on the exact bytes you crossed on, and reddens the day they change. It is opt-in because sealing only makes sense once a gate is genuinely green and you want to freeze that crossing; forcing a lock on every mission would be noise (ADR-0021).
Behavioral proof and the redone-task proof
These are two distinct things; keep them apart.
The floor's behavioral proof
The floor's behavioral proof is advisory and sits above the gate. runward reads runward/floor.md for a Behavioral proof: <command> line and an optional Proof artifact: <path>. It reports whether the artifact is present and fresh — fresh meaning its mtime is at least as new as the newest source file under <root>/code/ (and left undefined when there is no code/ directory).
It is strictly read-only: never executed, never parsed for pass/fail — running the suite and judging the result stays the code's job and the operator's. The gate prints this only when the documentary gate is already green, with an explicit note that "runward did not run your code — it is not a runtime. The behavioral proof is your test suite".
The redone-task proof
The redone-task proof is different: it is a gated CRITICAL rule, handover-redone-task-proof, mapped to the handover phase (impact: CRITICAL, phases: [handover]). It is accounted for in handover.md's conformance manifest like any other rule, typically with a typed pointer into the handover note's redone-task section (its "The redone task (the proof)" block), and it is verified by the same evidence layer above.
The doctrine behind it: a hand-over is proven the day the receiving team redoes a real task end to end, alone, without the departing builder — recorded, dated, pointed at. So the redone-task is inside the gate (a rule with evidence); the floor's behavioral proof is above it (advisory presence/freshness).
Also above the gate: verify findings
After the documentary gate is green, the check nudges you to run the adversarial cite-vs-apply verify workflow (runward/workflows/verify.md). Its output artifact is runward/governance/verify-findings.md; runward reports only its presence and freshness, never reads a verdict from it, and it never blocks the gate. Fresh here means newer than every gated manifest — edit a manifest after the pass and the findings go stale.
How silent erosion is caught
"Silent erosion" is the case where the paperwork still says green but the underlying reality moved. runward catches it deterministically on the next check --strict:
- A pointer's target file is deleted or moved → typed-pointer resolution fails or drift fires for prose refs.
- A function is renamed so a
#symbol/::testNameno longer appears → symbol/test-name check fails. - A pointed file is emptied → non-vacuity fails.
- The rule's shape is removed from a cited file → signature mismatch.
- A sealed file's bytes change while the pointer still resolves → SHA-256 mismatch reddens the gate.
- The rule mapping is stripped below its floor →
(mapping)violation. - A reconstructed decision is left unratified (DRAFT filename,
Status: hypothesis, orwhy: UNKNOWN) → the gate fails until it is ratified. - The behavioral proof or verify findings go stale (older than the code / a manifest changed since) → flagged as
stale, advisory.
What runward does not claim
The gate checks the presence and shape of a traced decision — that a high-impact rule is accounted for and its pointer resolves to real, non-empty, shape-matching content. It never judges whether the code truly implements the rule; that judgment stays the operator's, at the gate.
The output is audit-ready supporting evidence that feeds a compliance programme (runward compliance <regime>), not a compliance verdict — acceptance is the auditor's call.
runward is deterministic and zero-LLM/zero-network at the gate (the LLM lives in the harness that wrote the code). It never runs your code or your tests, and it never installs git hooks or writes to .git/ — the adapters are inert samples the operator wires on their own say-so (ADR-0012).
Why this choice
Why the SHA-256 seal
The SHA-256 seal (check --freeze) exists because the cheaper checks miss one failure mode. Drift and typed-pointer resolution catch a pointer that stops resolving, and the signature check catches a pointer aimed at a file lacking the rule's shape — but none of them catch a pointer that still resolves to a file whose contents silently changed after you crossed the gate. The rejected alternative is to trust that a resolving, non-empty pointer stays valid forever; the seal instead freezes the exact bytes crossed on and reddens the gate the day they change. It is opt-in (no lock file means no check) because sealing only has meaning once a gate is genuinely green; a mandatory lock on every mission would be noise.
Why manifest --sync scaffolds only form
Separately, manifest --sync deliberately scaffolds only form and leaves every status empty rather than guessing 'applied' — the rejected alternative (auto-filling a plausible status) would let an agent's guess pass as an operator decision, so the gate refuses empty-status rows until a human decides.