Docs · Understand · Architecture & rationale
Architecture & rationale.
runward is a local, open-source CLI that frames the delivery of code an AI agent wrote: the agent writes the code, runward runs a deterministic gate over the decisions and evidence around it and returns a replayable verdict. It never writes your product code and never judges code quality — it checks that every load-bearing rule is accounted for with a resolvable evidence pointer. This page explains the shape (ports and adapters), the two invariants the whole design protects (deterministic, zero-LLM/zero-network gate), the rule set as reviewable data, and the ADR journal that records why each choice was made. The success criterion the architecture is built around: same working tree ⇒ same exit code, every violation named.
The whole architecture turns on one shape: the gate is a port, and everything else adapts to it.
The gate is a port, not an API
runward's primary port is a process contract, not a library API: arguments in, an exit code out, a human-readable report on stdout. Anything that can run a process and read an exit code is an adapter of that port — a CI job, a git pre-commit hook, or an AI agent harness.
The exit codes are the load-bearing part of the contract, consumed blind by CI:
| Exit code | Meaning | Consumer reaction |
|---|---|---|
0 |
gate clean (with --strict: every expected rule accounted for, every typed pointer verified, seal intact) |
cross the gate / merge |
1 |
gaps — unfilled deliverables, conformance violations, drift, broken seal, failed hooks | block; the report names each violation and its fix gesture |
2 |
no mission found, or CLI misuse (unknown command/flag) | configuration error, deliberately distinct from a red gate so a typo never reads as "gate red" |
The distinction between 1 and 2 is a design choice, not an accident: a red gate (1) and "the question could not even be asked" (2) must never collapse into one code, so a mistyped flag can never be mistaken for a failing audit.
Command signatures (for an agent driving runward)
runward check [--strict] [--hooks] [--coverage] [--freeze] [--json] [-p PATH] -> exit code + report
runward rules --json [-p PATH] -> versioned JSON on stdout
runward compliance <regime> [-p PATH] -> files under runward/compliance/
runward wire [--json] [-p PATH] -> recommended channel (read-only; never wires)
All operations are read-only on your code, synchronous, and idempotent; no operation ever requires approval because none acts on the world beyond writing into runward/.
runward wire is deliberately read-only: it detects the AI harness running it and recommends the auto-trigger channel, but never wires anything — the operator wires (ADR-0012).
--json on check is the stable machine surface for agent-driven runs (ADR-0030). It returns a single deterministic JSON object, with the exit code still the primary signal:
- on a found mission:
{ runward, mission, currentGate, adrCount, strict, verdict, exitCode, gaps, deliverables, conformance? } - when none is found:
{ runward, mission: null, verdict: "no-mission", exitCode: 2 }
Commands are adapters; the logic is a library
The internal split mirrors the external port. src/commands/ holds thin commander action handlers — parse options, format output, set the exit code — over pure logic in src/lib/ (manifest parsing, rule reading, evidence resolution, compliance rendering).
Dependencies point inward: commands imports lib, never the reverse, so every check is testable without a terminal. The CLI entry wires each subcommand to its handler and centralizes Commander's parse errors onto the exit-code contract — unknown command/flag and other misuse map to 2, distinct from a red gate's 1.
The two invariants, and why
Everything else in the design is negotiable; these two are not.
1. No model call, no network call can reach the verdict
The verdict must be deterministic and replayable, so there is deliberately no model port and no network port in the architecture.
Scope this precisely: it is the gate that is zero-LLM and zero-network. runward is not an AI-free tool — the LLM that writes the code lives in the operator's harness, one level up; any LLM judgment sits above the gate, never inside it.
The --strict conformance pass "never opens project code, never runs a test, and never judges whether the pointer truly implements the rule. That judgment stays the operator's".
2. runward is a frame, never a runtime
It reads the operator's files and writes only into runward/ and explicitly requested paths. A daemon, a file-watcher, or silent installation into .git/hooks/ would each turn the frame into a runtime — the one thing the design forbids (ADR-0012).
Zero-LLM/zero-network as a structural guarantee, not a promise
The CI (.github/workflows/ci.yml) enforces both invariants in two complementary ways:
- A best-effort static guard greps
src/for network/runtime modules, HTTP clients, model SDKs (openai, anthropic, cohere-ai, @ai-sdk, @aws-sdk, @google/generative-ai, …), globalfetch/WebSocket, dynamic network imports,eval/new Function, and network shell-outs (curl,wget,ssh, …). Any hit fails the build.child_processis allowed only for local git. - The durable proof: the core test execution runs inside a network namespace with no external interfaces —
sudo unshare -ncuts every route, and the job asserts no default route is present before runningtest/smoke.js, the OSCAL check, andrunward check --strict. If the core reached the network, the job fails. This turns the promise into a structural guarantee rather than a grep. The comment says it plainly: "This turns the zero-network promise into a structural guarantee, not a grep ofsrc/".
The same CI runs a self-gate — runward check --strict on runward's own mission — as a required check, so the tool passes the gate it ships.
The rule set is versioned data, not code
The gate's demands live in templates/rules/*.md — one markdown file per rule. Each carries frontmatter plus a prose body:
- load-bearing fields:
title,impact,phases - optional fields:
asi/OWASP-ASI mapping,tags, awhyfromimpactDescription, and an evidencesignature
Changing what the gate demands is therefore a data change under maintainer review, with reviewable diffs and no runtime fetch. There are 64 shipped rules.
One parser reads the full frontmatter shape and is shared by the machine surface (rules --json) and explain; the inventory is always sorted by slug for determinism. The effective rule set is the mission's own runward/rules/ copy when present, else the package's — so a mission can pin its rules.
Rule renames and removals are tracked as migrations, never silent rewrites: a manifest still citing an old slug is guided to its replacement (ADR-0006).
How the conformance gate reads that data (--strict)
--strict verifies, per gated build phase, that every CRITICAL/HIGH rule mapped to that phase is accounted for in the deliverable's "Rule conformance" markdown table:
appliedneeds an evidence pointerdeviatedneeds an existing ADRn/aneeds a real reason
The gated (phase → deliverable) pairs are a single source shared by check --strict, the evidence layer, and the compliance assembler:
| Phase | Deliverable |
|---|---|
| architect | architecture.md |
| topology | execution-topology.md |
| floor | floor.md |
| govern | governance/threat-model.md |
| handover | handover.md |
Two anti-cheat properties are built in:
- Non-vacuity (ADR-0002): each phase has a pinned floor of mapped CRITICAL/HIGH rules —
{ architect: 6, topology: 4, floor: 10, govern: 12, handover: 4 }— so thephases:mapping cannot be silently stripped to make--strictpass with nothing to check. - Drift (ADR-0004, blocking since ADR-0021): an
appliedpointer whose file path no longer resolves reddens the gate. Ann/areason that is a bracketed template placeholder or under 8 chars is rejected as trivial.
Reconstructed decisions are held to the same bar: an ADR still marked DRAFT-, Status: hypothesis, or why: UNKNOWN counts as unratified and fails the gate — "an agent's guess must not pass as a decision".
Adapters are inert samples the operator wires
Six harness adapters ship under templates/adapters/:
pre-commitgithub-actions.ymlgitlab-ci.ymlclaude-code-settings.jsonkiro-hooks.jsonbmad-review-layer.toml
They are inert leave-behinds: runward emits them, the operator copies and wires them. runward installs nothing, writes no file into .git/, edits no .claude/settings.json, registers no CI (ADR-0012). The expected-adapter count excludes the README so swapping a real adapter for a stray file cannot pass the count.
"Agent-operable" means an agent can drive runward end-to-end and may offer to wire the gate, acting only on the operator's explicit approval — never silently (ADR-0012).
The wire boundary is a deliberate authorization choice, framed as consent (the agent proposes, the operator decides), not a missing capability — which is exactly why runward wire only recommends and never acts. This is also why the design is vendor-neutral by construction: the port contract is the exit code and nothing else, so no harness is privileged and a new one is a new sample file, not a core change (ADR-0012).
Sealing, machine surface, and regime lenses (all versioned)
- Evidence seal —
check --freeze(implies--strict) refuses to seal a red gate, then hashes the resolvable evidence files intorunward/evidence-lock.json({ version: 1, sealedAt, files }, keys sorted, byte-idempotent). A sealed file that later changes or disappears failscheck --strictuntil re-verified (ADR-0021). - Machine rule surface —
rules --jsonemits{ runward, source, count, rules[] }, sorted by slug, additive (fields are added, never renamed or removed; consumers are tolerant readers) (ADR-0024). - Compliance pack —
compliance <regime>emits regime-framed markdown plus an OSCAL 1.2.2 component-definition, schema-valid against the NIST schema, deterministic for a given tree and date, and always labelled a readiness draft, never a compliance claim (ADR-0016). This is audit-ready supporting evidence that feeds a compliance programme; acceptance is the auditor's call, and security is the default lens (regulation is optional). Regime mappings are versioned data — a regulatory change is a newregimes/<regime>@<version>.jsonfile, never an edit of a shipped version, so a pack stays re-assemblable against the exact lens its auditor saw (ADR-0022). CI even proves the emitted OSCAL loads in a real third-party tool, IBM compliance-trestle.
Default topology and dependency policy (locked, with triggers)
The design deliberately stays a single-process CLI monolith, with nothing about language, topology, or dependency policy left open:
- Single-process CLI monolith: a gate audit is a short, local, read-mostly computation, and distribution would add failure modes for nothing.
- Language/runtime: TypeScript strict, ESM, Node ≥ 20 (
package.jsonengines). - Dependencies: exactly three runtime dependencies — commander, chalk, @inquirer/prompts — because every dependency is attack surface for a security tool; parsing, color, and prompts are the only things not worth owning.
Each of these is locked in the decision matrix with a named evolution trigger.
The ADR journal is the decision record
The full rationale for every structural choice lives in the ADR journal at docs/adr/ — 32 dated ADRs (ADR-0001 … ADR-0032), each with a mandatory, dated re-evaluation trigger.
These are runward's own decisions (the tool dogfooding the method it ships), and they are explicitly not shipped to npm and not a mission's ADRs — when you run runward init, your project gets its own runward/adr/ scaffolded from a template.
The load-bearing ones for this area:
| Decision | ADR |
|---|---|
| Deterministic per-rule gate; no model in the verdict | ADR-0001 |
| Strict gate hardened against vacuous passing | ADR-0002 |
| The gate as a port; adapters inert, operator-wired | ADR-0012 |
| Typed evidence pointers verified at the gate | ADR-0019 |
| Rule evidence signatures | ADR-0020 |
| Blocking drift + evidence sealing | ADR-0021 |
Machine surface of the rule set (rules --json) |
ADR-0024 |
For depth on any decision — its context, alternatives rejected, consequences, and re-evaluation trigger — go to the ADR reference at docs/adr/ (indexed by docs/adr/README.md). The architecture note points there for the authoritative record.
Note: the source
runward/architecture.mdis stamped v0.18.1 and still cites older ADR counts (24 / 28) while the journal now holds 32; the live figures above are taken directly from the repository, not from that note.
Why this choice
The gate as a port, not shipped automation
The central deliberate choice is treating the gate as a port (a process contract: argv in, exit code out) rather than shipping the automation to run it.
The rejected alternative — a runward daemon, file-watcher, or auto-installing a git hook into .git/hooks on init — would have made the gate run automatically, but at the cost of turning runward from a frame into a runtime and executing surprise code in a repo without the operator's act (ADR-0012). Instead runward ships inert adapter samples the operator wires, with an amendment letting an agent offer to wire on explicit approval.
No model port, no network port
The related choice — no model port, no network port — is what makes the verdict replayable; an LLM-judge gate was rejected because it is non-deterministic and would silently become the de-facto gate, reproducing the very cite-without-apply incident that started the work (ADR-0001).
Rules as versioned data
Keeping rules as versioned markdown data (not code) was chosen over a trimmed hard-coded rule set so changes are reviewable diffs under maintainer review with tracked migrations.
What comes next
The architecture note names the target explicitly:
- a broader curated rule set,
- additional regime lenses kept as versioned data (ADR-0022),
- more inert harness adapters,
- and a deeper brownfield reconstruction path (characterize, ADR-0013/0014).
All of it must enter behind the existing contracts and may not touch the two invariants (deterministic verdict, never a runtime).
The ADR-0012 re-evaluation trigger: reopen if a harness the community actually uses cannot be wired by a static sample without runward having to run/watch/install something — at which point a thin adapter library (still harness-invoked, never a daemon) would be reconsidered.