# Sweep dissolution — the real-data blueprint sweep as campaign sugar — Design Spec **Date:** 2026-07-04 **Status:** Draft — awaiting sign-off **Authors:** orchestrator + Claude Cycle 1 of the milestone "Verb dissolution — the campaign path as canonical orchestration" (anchor issue #210; design basis: the #210 coverage map + the fork-triage comment of 2026-07-04, all forks decided). This cycle re-cuts the **real-data blueprint branch** of `aura sweep` as thin sugar over a generated, auto-registered campaign document executed by the one campaign executor. The built-in and synthetic branches stay verb-wired (#210 decision 2); the other three verbs follow in later cycles riding on this cycle's translator skeleton (#210 decision 7). ## Goal `aura sweep --real SYM --from A --to B --axis k=v,… [--name N]` stops executing through its inline `run_blueprint_sweep` path and instead: 1. registers the blueprint (already shipped behaviour, kept), 2. generates a selection-free single-stage **process document** and a **campaign document** expressing exactly the invocation's intent, 3. auto-registers both into the content-addressed stores (#210 decision 6), 4. runs them through `aura-campaign`'s executor via the existing `CliMemberRunner`, 5. prints the same member lines as today's verb output, modulo one additive field: the campaign substrate stamps `"instrument":""` into each member's manifest (`campaign_run.rs` stamps `manifest.instrument`; the inline path leaves it `None` and serde omits it). The stamp is kept — it is provenance the substrate deliberately records; stripping it in sugar mode would fork the substrate's behaviour for presentation cosmetics. No existing test pins the field's absence on the dissolved branch (#210 decision 5, refined by the grounding-check finding of 2026-07-04). The invocation's intent — instrument, window, axes, strategy — thereby becomes durable, diffable, reproducible data instead of evaporating into shell history (#188 diagnosis). The inline real-data execution arm is deleted in the same cycle; "thin sugar" replaces the path, it never adds a parallel one. Prerequisite vocabulary change (#210 decision 1): the `std::sweep` stage's selection becomes optional — permitted only when the sweep is the terminal stage of its pipeline. ## Architecture Three layers change; the seams between them are all shipped: - **`aura-research`** — the `std::sweep` block's selection triple (`metric`, `select`, `deflate`) becomes an optional *group*: either all absent (a selection-free sweep) or `metric`+`select` present (`deflate` defaulting as today). A half-populated triple is unrepresentable — the hand-rolled schema-strict deserializer refuses `metric` without `select` and vice versa. Introspection (schema tables, `--node` describe) and the canonical form follow: the absent group serializes as absent fields, so every existing document's canonical bytes and content id are unchanged. - **`aura-campaign`** — preflight gains one structural rule: a selection-free sweep is permitted iff it is the pipeline's terminal stage (every downstream stage consumes the family's selection state or the nominee; refusing non-terminal selection-free sweeps keeps that contract intact). The executor skips `select_sweep_winner` for a selection-free sweep: no `StageSelection` is recorded (recording one would fabricate intent — the audit-lie all triage lenses rejected), no nominee is produced, the family is appended exactly as today. - **`aura-cli`** — a new translator module (the shared skeleton later verb cycles extend) maps the verb's argv to the two generated documents, auto-registers them, invokes the campaign run path, and presents the result through the verb's own stdout contract (member lines only; the campaign path's `selection_report` and final `campaign_run` stdout lines are suppressed in sugar mode — the run record itself is still appended to `campaign_runs.jsonl`). The `--real` arm of `run_blueprint_sweep`'s dispatch is replaced by this path; the synthetic arm and `--list-axes` stay untouched. ## Concrete code shapes ### The user-facing invocation (unchanged surface, new substrate) ```console $ aura sweep blueprints/signal.json --real GER40 \ --from 1725148800000 --to 1727740800000 \ --axis sma_fast.LENGTH=8,12,16 --name probe {"family_id":"…","report":{…}} {"family_id":"…","report":{…}} {"family_id":"…","report":{…}} ``` Stdout: one member line per grid point, the same `family_member_line` shape as today plus the additive `"instrument":"GER40"` manifest stamp (see Goal, point 5). New observable effects, all additive: ```console $ ls runs/processes runs/campaigns # both docs now exist, content-addressed runs/processes/.json runs/campaigns/.json $ aura campaign runs # the realized run is recorded {"campaign_run":{"campaign":"","name":"probe",…}} ``` ### The generated documents (exact intent, closed vocabulary) Process document (constant for every selection-free sweep — repeated ad-hoc sweeps dedupe onto one stored doc by content id): ```json { "format_version": 1, "kind": "process", "name": "sweep", "pipeline": [ { "block": "std::sweep" } ] } ``` Campaign document (one per distinct invocation shape; `--name` becomes the campaign name, defaulting as the verb defaults today): ```json { "format_version": 1, "kind": "campaign", "name": "probe", "data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1725148800000, "to_ms": 1727740800000 } ] }, "strategies": [ { "ref": { "content_id": "" }, "axes": { "sma_fast.LENGTH": { "kind": "I64", "values": [8, 12, 16] } } } ], "process": { "ref": { "content_id": "" } }, "seed": 0, "presentation": { "persist_taps": [], "emit": ["family_table"] } } ``` `seed: 0`: no stage of a selection-free single-sweep pipeline consumes the seed (no deflation, no bootstrap); a fixed zero keeps the generated bytes deterministic so identical invocations produce identical content ids. ### Vocabulary change, before → after (`aura-research`) ```rust // before StageBlock::Sweep { metric: String, select: SelectRule, deflate: bool } // after — the selection triple is one optional group StageBlock::Sweep { selection: Option } pub struct SweepSelection { pub metric: String, pub select: SelectRule, pub deflate: bool } ``` Wire form is unchanged for existing documents: `{"block":"std::sweep", "metric":"…","select":"…"}` parses into `Some(SweepSelection{…})`; `{"block":"std::sweep"}` parses into `None`; `metric` without `select` (or vice versa) is a schema error naming the incomplete group. Serialization omits the absent group, so canonical bytes of every existing document are byte-identical and no stored content id moves. ### Preflight rule, before → after (`aura-campaign`) ```rust // before: sweep always selects; nothing to check. // after: a selection-free sweep must be terminal. if sweep.selection.is_none() && !is_terminal_stage { return Err(ExecFault::SelectionFreeSweepNotTerminal { … }); } ``` There is no separate preflight fault type — `preflight` returns `ExecFault` (`aura-campaign/src/lib.rs`), whose prose lives in `aura-cli`'s exhaustive `exec_fault_prose` match; the new variant extends both (compiler-enforced). Executor arm: `selection: None` → append the family, emit `family_table` lines per the presentation, record the stage realization **without** a `StageSelection`, produce no nominee. ### Verb dispatch, before → after (`aura-cli`) ```rust // before (dispatch_sweep, blueprint branch, --real present): run_blueprint_sweep(doc, axes, name, persist, DataSource::Real{…}) // inline exec // after: let gen = translate_sweep(&argv)?; // argv → (ProcessDoc, CampaignDoc) let ids = register_generated(®, &gen)?; // auto-register both, content-addressed run_generated_campaign(®, env, ids, RunPresentation::MemberLinesOnly)?; // The presentation mode is a parameter threaded through the existing // campaign-run entry (`campaign_run.rs`): `aura campaign run` keeps the // full mode (member lines + selection_report + final campaign_run line); // sugar mode suppresses everything but the family_table member lines. // The record append is untouched in both modes. ``` The synthetic arm (`--real` absent) keeps calling the existing inline path unchanged; `--list-axes` keeps its probe path unchanged. ## Components 1. **`aura-research`**: `SweepSelection` extraction; hand-rolled deserializer update (group-or-nothing rule + refusal prose); canonical-form serialization keeps omit-absent; introspection contract updated (the sweep block's schema table marks the selection group optional-terminal); intrinsic validation unchanged otherwise. 2. **`aura-campaign`**: preflight terminal-only rule + fault variant with house-style prose; executor selection-free arm (no `StageSelection`, no nominee); `PER_MEMBER_METRICS`/`RANKABLE_METRICS` untouched. 3. **`aura-cli`**: `verb_sugar` translator module — `translate_sweep` (argv → documents; CSV axis parsing reuses the shipped `--axis` parser and the axis-kind derivation from the blueprint's open params), generated-doc registration, sugar-mode presentation (suppress `selection_report` + `campaign_run` stdout lines; member lines pass through), dispatch rewire of the `--real` blueprint arm, deletion of the now-dead inline real-data execution arm of `run_blueprint_sweep`. ## Data flow ``` argv ──translate_sweep──▶ ProcessDoc + CampaignDoc (canonical JSON) ──register_generated──▶ runs/processes/.json, runs/campaigns/.json ──campaign run path──▶ preflight ▶ executor ▶ CliMemberRunner (real archive) ├─▶ families.jsonl (FamilyKind::Sweep, as today) ├─▶ campaign_runs.jsonl (the realized-intent record) └─▶ stdout: family_table member lines ONLY (sugar mode) ``` Family naming follows the campaign convention (`{campaign8}-…-w0-s0`); the user's `--name` handle lives on the campaign document and its run record. The member stdout lines never carried the family name; their only delta against the inline path is the additive `instrument` manifest stamp (Goal, point 5). ## Error handling - Verb-surface refusals keep the shipped exit-code contract: usage errors exit 2 (clap, unchanged), runtime refusals exit 1 with `aura: …` prose. - Translator refusals (unknown axis name, malformed CSV, missing geometry) reuse the existing refusal prose paths — the pinned rejection tests keep their bytes. - Preflight faults surface through the existing campaign fault prose; the new `SelectionFreeSweepNotTerminal` prose names the rule and the fix ("a sweep without a selection must be the last stage"). - A generated document that fails validation is a bug, not a user error — the translator's output is preflighted before any member runs, so a fault aborts with exit 1 before touching the archive. ## Testing strategy 1. **Characterization first (the reproduction evidence) — SHIPPED.** The pin test `sweep_real_blueprint_member_lines_pin_the_inline_contract` (`crates/aura-cli/tests/cli_run.rs`, gated, green on this host) pins the inline path's observable behaviour: member count (exactly 4 stdout lines) and odometer order, the swept bindings in `manifest.params`, exactly one `FamilyKind::Sweep` family with 4 members — and, as of today, the *absence* of the `instrument` manifest key on the inline path. The re-cut task flips **exactly that one assertion** (absence → equals the `--real` symbol, the sanctioned additive delta); every other assertion survives unchanged, including the exactly-4-lines pin, which forces sugar mode to suppress the campaign path's final `campaign_run` stdout line. The sibling pin (the campaign runner's stamp) lives in `campaign_run_real_e2e_sweep_gate_walkforward` and is untouched. 2. **Translator unit tests (ungated, pure).** argv → exact canonical JSON bytes of both generated documents (including `seed: 0`, axis kinds, the `content_id` refs); identical invocations → identical content ids; `--name` flows to the campaign name. 3. **Vocabulary tests (ungated, `aura-research`).** Selection-free sweep parses/serializes/round-trips; half-populated group refused with named prose; existing fixture documents' canonical bytes and content ids unchanged (the no-drift pin). 4. **Preflight tests (`aura-campaign`).** Selection-free terminal sweep accepted; selection-free sweep followed by any stage refused; executor records no `StageSelection` and produces no nominee for the selection-free arm. 5. **Existing suite green unchanged.** All current sweep tests (synthetic, built-in, `--list-axes`, rejection prose) stay untouched and green — they pin the branches this cycle does not dissolve. ## Acceptance criteria - The `--real` blueprint form of `aura sweep` executes through the campaign executor; its inline real-data execution arm is deleted (no parallel path). - Both generated documents are auto-registered content-addressed on every sugar run; a `campaign_run` record is appended; repeated identical invocations dedupe onto the same document ids. - Member-line stdout of the dissolved form is identical to the pre-change verb modulo the additive `instrument` manifest stamp: the shipped characterization pin survives the re-cut with exactly one sanctioned assertion flip (instrument-absence → instrument-equals-symbol); all its other assertions — including exactly 4 stdout lines — are untouched. - A selection-free `std::sweep` is a valid terminal stage of the document vocabulary — parseable, introspectable, content-addressable — and refused with named prose anywhere else in a pipeline; no existing document's content id moves. - Synthetic/built-in sweep branches, `--list-axes`, and all their pinned tests are untouched. - `cargo test --workspace` green; `cargo clippy --workspace --all-targets -- -D warnings` clean.