# `aura run` — end-to-end sample-harness CLI — Design Spec

**Date:** 2026-06-04
**Status:** Draft — awaiting user spec review
**Authors:** orchestrator + Claude

> Cycle 0010. Tracker: Gitea issue #8 (Brummel/Aura), milestone *Walking
> skeleton* — the skeleton's **closing seam**. Consumes the cycle-0009 report
> surface (#6) and the cycle-0007 signal-quality nodes (#4/#5).

## Goal

`aura run` bootstraps a built-in **sample harness** (source → SMA-cross signal →
`Exposure` → `SimBroker` → recording sinks), runs it deterministically (C1), and
prints the structured **metrics + manifest** (#6) as canonical JSON to stdout —
the headline C14 "run a sim, emit structured metrics" move, end-to-end, from a
real binary for the first time. The sample harness ships **with sinks** so a
newcomer sees a populated trace immediately (C22).

## The crux this cycle resolves

There is **no shipped recording sink node** today — recording (C22 / cycle 0006)
exists only as `#[cfg(test)]` fixtures in `harness.rs` and `report.rs`. Since
`Harness::run` returns `()` and a sink is the *only* data-out path, a runnable
CLI is impossible without first shipping a reusable recording node. This cycle
therefore ships one (`aura-std::Recorder`) as its first component, then wires the
CLI on top.

## Non-goals (out of scope)

- **The experiment-builder API / fluent `HarnessBuilder` DSL.** The ledger lists
  it under "Open architectural threads not yet resolved"; this cycle does **not**
  commit it. The sample harness is authored in Rust as a plain constructor
  function over the existing raw `Harness::bootstrap(nodes, sources, edges)` API
  — that *is* "harness wiring in Rust" (C17/C20). The elaborate builder is a
  later cycle.
- **`aura new` scaffolder and the `Aura.toml` schema** — deferred architectural
  threads, untouched.
- **The data-server source (#7).** The sample runs on a built-in synthetic
  stream; the source is swappable at the ingestion boundary (C3) when #7 lands.
- **A git-commit build script.** The manifest `commit` is filled from
  `option_env!("AURA_COMMIT")` (defaults to `"unknown"`); capturing the real
  HEAD via `build.rs` is deferred.
- **An argument-parsing dependency (clap/…).** The workspace is deliberately
  zero-dependency; `aura run` hand-parses `std::env::args` for one subcommand.
- **Refactoring the existing `#[cfg(test)]` Recorder fixtures** to use the
  shipped node. They stay as historical snapshots; the shipped `Recorder` is the
  go-forward reusable one. De-duplication is a possible later tidy.

## Architecture

Two deliverables, in dependency order:

1. **`aura-std::Recorder`** — a reusable recording node (the glossary *sink*
   role): a pure consumer (`output: vec![]`, C8) that holds an
   `mpsc::Sender<(Timestamp, Vec<Scalar>)>` as its out-of-graph destination and,
   on every fired cycle, sends `(ctx.now(), row)` where `row` is the newest value
   of each declared input column. `mpsc` (std) keeps aura-std zero-external-dep
   and avoids the `Rc`/`RefCell` interior mutability the purity invariant (C7)
   forbids — the same destination shape the test fixtures already use. Supports
   all four scalar kinds (a sink must be able to record any base column, C22),
   though the CLI uses it only for f64.

2. **`aura-cli`** gains a `run` subcommand:
   - `fn sample_harness() -> (Harness, Receiver<…>, Receiver<…>)` — composes the
     `aura-std` nodes (`Sma`/`Sub`/`Exposure`/`SimBroker` + two `Recorder`s) via
     `Harness::bootstrap`, returning the harness and the two sink receivers
     (equity, exposure). This is the Rust-authored harness (C17/C20); `aura-cli`
     legitimately depends on both `aura-engine` and `aura-std`, so the engine
     stays domain-free (it never names SMA/exposure — cycle-0007 note).
   - `fn run_sample() -> RunReport` — bootstrap → `run` on a built-in synthetic
     price stream → drain both sinks → `f64_field` → `summarize` → pair with a
     `RunManifest` → return the `RunReport`. Pure and deterministic (C1): same
     build → same report.
   - `fn main()` — hand-parse `args`: `run` ⇒ `println!("{}", run_sample().to_json())`,
     exit 0; anything else ⇒ a one-line usage message to **stderr**, exit 2.

`aura-cli` adds `aura-std` to its dependencies (it currently depends only on
`aura-engine`).

## Concrete code shapes

### User-facing: the invocation and its output (the Step-4 evidence)

```console
$ aura run
{"manifest":{"commit":"unknown","params":{"sma_fast":2,"sma_slow":4,"exposure_scale":0.5},"window":[1,7],"seed":0,"broker":"sim-optimal(pip_size=0.0001)"},"metrics":{"total_pips":<e.g. 3>,"max_drawdown":<e.g. 2>,"exposure_sign_flips":<e.g. 1>}}
$ echo $?
0
$ aura
aura: usage: aura run        # (to stderr)
$ echo $?
2
```

The exact metric values are pinned by the Testing strategy below (they follow
from the chosen synthetic stream); the JSON **shape** is the cycle-0009
documented schema. The synthetic stream is chosen to rise then reverse, so the
demo trace is non-trivial (a sign flip and a drawdown), per C22's "populated
trace".

### Delivered: `aura-std::Recorder` (the sink node)

```rust
use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind, Timestamp};
use std::sync::mpsc::Sender;

/// A recording sink (the glossary *sink* role, C8/C22): a pure consumer that,
/// each fired cycle, sends `(ctx.now(), row)` — the newest value of each input
/// column — to an out-of-graph `mpsc` destination it holds. Returns `None`
/// (records, forwards nothing). `mpsc` keeps the engine's purity invariant (C7):
/// no `Rc`/`RefCell`. Returns `None` (filters) until every input column is warm.
pub struct Recorder {
    kinds: Vec<ScalarKind>,
    firing: Firing,
    tx: Sender<(Timestamp, Vec<Scalar>)>,
}

impl Recorder {
    /// A recorder over `kinds.len()` input columns of the given kinds, each with
    /// the given firing policy, sending recorded rows to `tx`.
    pub fn new(kinds: &[ScalarKind], firing: Firing, tx: Sender<(Timestamp, Vec<Scalar>)>) -> Self { /* … */ }
}

impl Node for Recorder {
    fn schema(&self) -> NodeSchema { /* inputs: one InputSpec per kind (lookback 1, self.firing); output: vec![] */ }
    fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
        // read newest of each input column by kind; None until all warm;
        // tx.send((ctx.now(), row)); return None.
    }
}
```

### Delivered: the CLI seam (`aura-cli/src/main.rs`)

```rust
use aura_engine::{f64_field, summarize, Edge, Harness, RunManifest, RunReport, SourceSpec, Target};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use std::sync::mpsc::{self, Receiver};

/// The built-in synthetic price stream: rises then reverses so the demo trace
/// carries a sign flip and a drawdown (C22 populated trace).
fn synthetic_prices() -> Vec<(Timestamp, Scalar)> { /* deterministic ticks t=1.. */ }

/// Bootstrap the sample signal-quality harness with two recording sinks
/// (equity on the SimBroker, exposure on the Exposure node). Rust-authored
/// wiring (C17/C20) over the raw bootstrap API — no builder DSL this cycle.
fn sample_harness() -> (Harness, Receiver<(Timestamp, Vec<Scalar>)>, Receiver<(Timestamp, Vec<Scalar>)>) { /* … */ }

/// Run the sample harness and fold it into a RunReport (drain → f64_field →
/// summarize → RunManifest). Deterministic (C1): same build → same report.
fn run_sample() -> RunReport { /* … */ }

fn main() {
    let mut args = std::env::args().skip(1);
    match args.next().as_deref() {
        Some("run") => println!("{}", run_sample().to_json()),
        _ => {
            eprintln!("aura: usage: aura run");
            std::process::exit(2);
        }
    }
}
```

### Implementation-shape note (secondary)

The only changed existing file is `crates/aura-cli/src/main.rs` (stub → the
above) plus `crates/aura-cli/Cargo.toml` (+`aura-std`, +`aura-core` path deps)
and `crates/aura-std/src/lib.rs` (+`mod recorder; pub use recorder::Recorder;`).
No `aura-engine` / `Harness` / node-contract change.

## Components

- **`Recorder`** (`aura-std/src/recorder.rs`) — the shipped sink node.
- **`synthetic_prices`** — the built-in deterministic source stream.
- **`sample_harness`** — the Rust-authored sample harness builder (aura-cli).
- **`run_sample`** — bootstrap→run→drain→reduce→report (aura-cli).
- **`main`** — arg dispatch + stdout/stderr/exit-code policy.

## Data flow

`synthetic_prices` → source → SMA(2)/SMA(4) → `Sub` → `Exposure` → `SimBroker`;
the price also taps directly into the broker's price slot (cycle-0007 wiring).
Two `Recorder`s tap the `SimBroker` equity output and the `Exposure` output; each
holds an `mpsc::Sender`. After `Harness::run` returns, `run_sample` drains both
receivers (`rx.try_iter().collect()`), projects field 0 with `f64_field`,
`summarize`s the two streams, pairs the metrics with a `RunManifest`
(`commit` from `option_env!`, `params`/`window`/`seed`/`broker` from the sample's
known configuration), and returns the `RunReport`. `main` prints its `to_json`.

## Error handling

- Unknown / missing subcommand → one-line usage to **stderr**, `exit(2)`. (`run`
  is the only verb this cycle.)
- `run_sample` is total: the sample harness is fixed and valid, so
  `Harness::bootstrap` cannot fail (the test pins this); `f64_field` /
  `summarize` are the cycle-0009 contracts (f64-only sinks, so no kind-mismatch
  panic on the wired columns). No `Result` plumbing in the happy path.
- `Recorder::eval` returns `None` until every input column is warm, then sends;
  a dropped receiver makes `tx.send` error, which is ignored (the World owns the
  receiver lifetime, exactly as the fixtures do).

## Testing strategy

- **`aura-std` `Recorder` unit test:** drive a tiny harness (or the node
  directly) so the recorder captures a known f64 stream; assert the drained
  `(Timestamp, Vec<Scalar>)` rows match, and that it returns `None` during
  warm-up (pure consumer). Mirrors the fixture's proven behaviour, now on the
  shipped node.
- **`aura-cli` `run_sample` unit test** (`#[cfg(test)] mod tests` in `main.rs`):
  call `run_sample()` twice; assert the two `RunReport`s are equal (determinism,
  C1) and that the metrics equal the hand-computed values for the chosen
  synthetic stream (pins `total_pips` / `max_drawdown` / `exposure_sign_flips`,
  and a non-zero drawdown + ≥1 sign flip so the demo trace is non-trivial).
- **`aura-cli` CLI integration test** (`tests/cli_run.rs`): spawn the built
  binary via `std::process::Command::new(env!("CARGO_BIN_EXE_aura"))`:
  - `run` → exit 0, stdout is exactly `run_sample().to_json()` + newline
    (assert the manifest+metrics keys + a parseable single-line object);
  - no args → exit 2, stderr contains `usage`.

Gates (profile `commands`): `cargo test --workspace`,
`cargo clippy --workspace --all-targets -- -D warnings`,
`RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps`.

## Load-bearing decisions flagged for review

1. **The recording sink ships as `aura-std::Recorder`** (not `aura-engine`, not
   `aura-cli`-local). Rationale: it is a *universal block* (C16 — aura-std's
   remit), domain-free, and reusable by any World/harness; `aura-engine` stays
   the bare engine (Harness + report, no nodes). It holds an `mpsc::Sender` — the
   purity-preserving destination the fixtures already validated.
2. **Minimal harness authoring: a plain Rust constructor function, no builder
   DSL.** This satisfies C17/C20 ("wiring is Rust") and closes the skeleton
   without prematurely committing the open experiment-builder-API thread. If you
   want this cycle to instead introduce a first slice of a real `HarnessBuilder`
   API, say so at review — that is a materially bigger cycle and I'd recommend
   splitting it out.
3. **Zero-dependency CLI**: hand-parsed args, no clap; `commit` via
   `option_env!("AURA_COMMIT")` defaulting to `"unknown"` (no `build.rs` git
   capture yet). Both keep the cycle minimal and the workspace dep-free; both are
   trivially upgraded later.

## Acceptance criteria

- [ ] `aura run` bootstraps and runs the sample harness end-to-end and exits 0.
- [ ] The sample harness includes (two) sinks; a run records displayable traces
      (equity + exposure), drained by the World.
- [ ] The run emits the structured metrics + manifest from #6 as canonical JSON
      on stdout.
- [ ] Harness wiring is Rust (a constructor function over the bootstrap API); the
      CLI's `main` holds no strategy logic (it only selects `run` and prints).
- [ ] A reusable `aura-std::Recorder` sink node ships and is unit-tested.
- [ ] An integration test drives the real binary (`run` → exit 0 + JSON stdout;
      bad args → exit 2 + usage stderr).
- [ ] `cargo test --workspace`, clippy `-D warnings`, and `cargo doc -D warnings`
      are clean; the workspace stays zero-(external-)dependency.
