# 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. > **Diagram.** runward’s primary port is a process contract, not an API: argv in, an exit code out (0 clean, 1 gaps, 2 no mission). Anything that runs a process (GitHub Actions, a pre-commit hook, an agent harness) is an adapter of this port. There is deliberately no model port and no network port: the verdict stays deterministic. ## 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 [-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, …), global `fetch`/`WebSocket`, dynamic network imports, `eval`/`new Function`, and network shell-outs (`curl`, `wget`, `ssh`, …). Any hit fails the build. `child_process` is allowed *only* for local git. - The **durable proof**: the core test execution runs inside a network namespace with no external interfaces — `sudo unshare -n` cuts every route, and the job asserts no default route is present before running `test/smoke.js`, the OSCAL check, and `runward 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 of `src/`". 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`, a `why` from `impactDescription`, and an evidence `signature` 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: - `applied` needs an evidence pointer - `deviated` needs an existing ADR - `n/a` needs 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 the `phases:` mapping cannot be silently stripped to make `--strict` pass with nothing to check. - **Drift** (ADR-0004, blocking since ADR-0021): an `applied` pointer whose file path no longer resolves reddens the gate. An `n/a` reason 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-commit` - `github-actions.yml` - `gitlab-ci.yml` - `claude-code-settings.json` - `kiro-hooks.json` - `bmad-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](https://runward.dev/docs/en/concepts/evidence/)** — `check --freeze` (implies `--strict`) refuses to seal a red gate, then hashes the resolvable evidence files into `runward/evidence-lock.json` (`{ version: 1, sealedAt, files }`, keys sorted, byte-idempotent). A sealed file that later changes or disappears fails `check --strict` until re-verified (ADR-0021). - **Machine rule surface** — `rules --json` emits `{ runward, source, count, rules[] }`, sorted by slug, **additive** (fields are added, never renamed or removed; consumers are tolerant readers) (ADR-0024). - **Compliance pack** — `compliance ` 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 new `regimes/@.json` file, 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.json` `engines`). - **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](https://runward.dev/docs/en/reference/adr/) at `docs/adr/` (indexed by `docs/adr/README.md`). The architecture note points there for the authoritative record. > Note: the source `runward/architecture.md` is 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. ## See also - [Concepts: the deterministic gate](https://runward.dev/docs/en/concepts/the-gate/) - [Rule catalog](https://runward.dev/docs/en/reference/rules/) - [ADRs / decisions](https://runward.dev/docs/en/reference/adr/) - [What's next: the roadmap](https://runward.dev/docs/en/project/roadmap/)