diff --git a/crates/aura-bench/baselines/cli_fixed_cost.json b/crates/aura-bench/baselines/cli_fixed_cost.json index e95f0b7..1efd69e 100644 --- a/crates/aura-bench/baselines/cli_fixed_cost.json +++ b/crates/aura-bench/baselines/cli_fixed_cost.json @@ -1,16 +1,16 @@ { "surface": "cli_fixed_cost", "metrics": { - "help_ms": 1.5046899999999999, - "run_ms": 3.63305 + "help_ms": 1.487171, + "run_ms": 3.376192 }, - "fingerprint": "run_line_fnv=6bb0d796f760d140", + "fingerprint": "run_line_fnv=9bdbc3acf7b2926a", "reps": 3, "host": { "hostname": "Raki", "nproc": 24 }, "profile": "release", - "commit": "9c7f60b", - "date": "2026-07-23" + "commit": "10570b7", + "date": "2026-07-25" } diff --git a/crates/aura-cli/src/graph_construct.rs b/crates/aura-cli/src/graph_construct.rs index 480865f..6cf2ad6 100644 --- a/crates/aura-cli/src/graph_construct.rs +++ b/crates/aura-cli/src/graph_construct.rs @@ -893,19 +893,31 @@ pub(crate) fn composite_from_any(text: &str, env: &aura_runner::project::Env) -> /// function — C29's "registered artifacts are never retroactively /// invalidated" stays intact. /// -/// **One deliberate exception:** `aura run `'s loaded-blueprint -/// branch (`dispatch_run`, main.rs) also reads a fresh FILE and reaches the -/// same unsanitized `traces//` seam (via `run_signal_r`/`run_measurement` -/// -> `bind_tap_plan` -> `TraceStore::begin_run`), so it too must gate the root -/// name — but it does NOT route through this wrapper, because its grammar is -/// deliberately narrower than `composite_from_any`'s (envelope-only, no -/// op-script array fallback; a bare `blueprint_from_json` call with its own -/// load-error prose/hint convention). Routing it through this wrapper would -/// silently loosen that grammar to also accept op-scripts. It instead calls -/// [`gate_authored_root_name`] directly, sharing the identical `name_gate` + -/// `name_gate_fault_prose` primitives this wrapper uses — so the refusal wording -/// is byte-identical across all five authored-file-intake routes even though -/// the shape-discrimination step is not shared by `dispatch_run`. +/// **Deliberate exceptions — direct `gate_authored_root_name` callers +/// (main.rs).** A handful of fresh-FILE intakes reach the same unsanitized +/// `traces//` seam (or `put_blueprint` the loaded envelope straight +/// into the registry) without going through this wrapper, because each +/// already parses the document its own way and only needs the shared root- +/// name gate bolted on, not the shape-discrimination `composite_from_any` +/// does: +/// - `aura run ` (`dispatch_run`): envelope-only grammar (no +/// op-script fallback), feeds `signal.name()` into +/// `run_signal_r`/`run_measurement` -> `bind_tap_plan` -> +/// `TraceStore::begin_run`. +/// - `validate_and_register_axes` (shared by `generalize`, +/// `sweep --real`, `walkforward --real`, `mc --real`): `put_blueprint`s +/// the loaded envelope by topology hash before any of those four verbs +/// touches an archive. +/// - `run_blueprint_sweep` / `run_blueprint_walkforward` / `run_blueprint_mc` +/// (the synthetic, no-`--real` family builders): same `put_blueprint` +/// reason, on the routes that bypass `validate_and_register_axes` +/// entirely (#331 cycle-close — these used to plant an ungated envelope +/// in the store; see each fn's own comment). +/// +/// All of these share the identical `name_gate` + `name_gate_fault_prose` +/// primitives this wrapper uses, so the refusal wording is byte-identical +/// across every authored-file-intake route even though the shape- +/// discrimination step is not shared by them. pub(crate) fn composite_from_authored_text( text: &str, env: &aura_runner::project::Env, diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 844441b..0cfdf23 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -901,6 +901,18 @@ fn run_blueprint_sweep( env: &aura_runner::project::Env, ) { let _ = persist; // reserved for the deferred per-member trace path; the family record below is unconditional + // #331 cycle-close: this fn `put_blueprint`s the loaded fresh-FILE + // envelope into the registry below — gate the root name up front, + // before the family ever builds (a shape-violating name can otherwise + // confuse the wrapped/raw axis resolution the family build performs, + // producing a misleading unrelated error instead of this refusal; see + // `gate_authored_root_name`'s doc comment for the full route list). + let blueprint = blueprint_from_json(doc, &|t| env.resolve(t)) + .expect("doc parse-validated at the dispatch boundary"); + if let Err(msg) = graph_construct::gate_authored_root_name(blueprint.name()) { + eprintln!("aura: {msg}"); + std::process::exit(2); + } let family = blueprint_sweep_family(doc, axes, &data, env).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(2); @@ -915,11 +927,7 @@ fn run_blueprint_sweep( .topology_hash .clone() .expect("a blueprint sweep stamps every member's topology_hash"); - let canonical = blueprint_to_json( - &blueprint_from_json(doc, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary"), - ) - .expect("a loaded blueprint re-serializes"); + let canonical = blueprint_to_json(&blueprint).expect("a loaded blueprint re-serializes"); reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(1); @@ -947,6 +955,16 @@ fn run_blueprint_walkforward( doc: &str, axes: &[(String, Vec)], name: &str, data: DataSource, select: Selection, env: &aura_runner::project::Env, ) { + // #331 cycle-close: this fn `put_blueprint`s the loaded fresh-FILE + // envelope into the registry below — gate the root name up front, + // before the family ever builds (see `run_blueprint_sweep`'s identical + // comment on why this must precede the family build, not just the + // registry write). + let blueprint = blueprint_from_json(doc, &|t| env.resolve(t)).expect("doc parse-validated at the dispatch boundary"); + if let Err(msg) = graph_construct::gate_authored_root_name(blueprint.name()) { + eprintln!("aura: {msg}"); + std::process::exit(2); + } let result = blueprint_walkforward_family(doc, axes, &data, select, env); let reg = env.registry(); let topo = result.windows[0] @@ -956,10 +974,7 @@ fn run_blueprint_walkforward( .topology_hash .clone() .expect("a blueprint walk-forward stamps every member's topology_hash"); - let canonical = blueprint_to_json( - &blueprint_from_json(doc, &|t| env.resolve(t)).expect("doc parse-validated at the dispatch boundary"), - ) - .expect("a loaded blueprint re-serializes"); + let canonical = blueprint_to_json(&blueprint).expect("a loaded blueprint re-serializes"); reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(1); }); let id = match reg.append_family(name, FamilyKind::WalkForward, &walkforward_member_reports(&result)) { Ok(id) => id, @@ -983,6 +998,17 @@ fn run_blueprint_walkforward( /// `FamilyKind::MonteCarlo` family (C18/C21 lineage), and print each draw's member line /// (carrying the seed) plus the aggregate — mirroring `run_blueprint_sweep`. fn run_blueprint_mc(doc: &str, n_seeds: u64, name: &str, data: DataSource, env: &aura_runner::project::Env) { + // #331 cycle-close: this fn `put_blueprint`s the loaded fresh-FILE + // envelope into the registry below — gate the root name up front, + // before the family ever builds (see `run_blueprint_sweep`'s identical + // comment on why this must precede the family build, not just the + // registry write). + let blueprint = blueprint_from_json(doc, &|t| env.resolve(t)) + .expect("doc parse-validated at the dispatch boundary"); + if let Err(msg) = graph_construct::gate_authored_root_name(blueprint.name()) { + eprintln!("aura: {msg}"); + std::process::exit(2); + } let family = blueprint_mc_family(doc, n_seeds, &data, env).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(2); @@ -995,11 +1021,7 @@ fn run_blueprint_mc(doc: &str, n_seeds: u64, name: &str, data: DataSource, env: .topology_hash .clone() .expect("a blueprint mc stamps every member's topology_hash"); - let canonical = blueprint_to_json( - &blueprint_from_json(doc, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary"), - ) - .expect("a loaded blueprint re-serializes"); + let canonical = blueprint_to_json(&blueprint).expect("a loaded blueprint re-serializes"); reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(1); @@ -1699,13 +1721,19 @@ fn parse_axes( Ok(axes) } -/// The three distinguishable ways `validate_and_register_axes` can fail: an -/// unknown axis name is a usage error (exit 2, echoed before the archive is -/// touched); a registry write failure is a runtime error (exit 1); running -/// outside a project (no `Aura.toml` found up from cwd, #218's gate) is also -/// a runtime error (exit 1) — the strategy document cannot resolve against a -/// project store/vocabulary that doesn't exist. +/// The four distinguishable ways `validate_and_register_axes` can fail: a +/// shape-violating root name is a usage error (exit 2, echoed before the +/// blueprint ever reaches the registry — #331 cycle-close: this fn +/// `put_blueprint`s a fresh-FILE envelope and so must gate it exactly like +/// `register`/`run`/the other authored-file intakes, see +/// `gate_authored_root_name`'s doc comment); an unknown axis name is a usage +/// error (exit 2, echoed before the archive is touched); a registry write +/// failure is a runtime error (exit 1); running outside a project (no +/// `Aura.toml` found up from cwd, #218's gate) is also a runtime error — +/// exit 1, since the strategy document cannot resolve against a project +/// store/vocabulary that doesn't exist. enum AxisRegisterError { + BadName(String), UnknownAxis(String), Registry(String), NoProject(String), @@ -1738,6 +1766,10 @@ fn validate_and_register_axes( let wrapped_open = blueprint_axis_probe(doc, env).param_space(); let blueprint = blueprint_from_json(doc, &|t| env.resolve(t)) .expect("doc parse-validated at the dispatch boundary"); + // #331 cycle-close: this fn `put_blueprint`s a fresh-FILE envelope below — + // gate the root name before it lands in the registry (shared choke point + // with every other authored-file intake). + graph_construct::gate_authored_root_name(blueprint.name()).map_err(AxisRegisterError::BadName)?; let raw_bound: HashSet = blueprint.bound_param_space().into_iter().map(|b| b.name).collect(); for (n, _) in axes { @@ -1774,11 +1806,16 @@ fn validate_and_register_axes( /// Exit-map for a `validate_and_register_axes` failure — the stderr line and /// exit code every campaign dispatcher (sweep/generalize/walkforward/mc) -/// preserves: an unknown axis is a usage error (exit 2, echoed before the -/// archive is touched); a registry write failure or a missing project (no -/// `Aura.toml` found up from cwd, #218's gate) is a runtime error (exit 1). +/// preserves: a shape-violating root name (#331) or an unknown axis is a +/// usage error (exit 2, echoed before the archive is touched); a registry +/// write failure or a missing project (no `Aura.toml` found up from cwd, +/// #218's gate) is a runtime error (exit 1). fn exit_axis_register_error(e: AxisRegisterError) -> ! { match e { + AxisRegisterError::BadName(m) => { + eprintln!("aura: {m}"); + std::process::exit(2) + } AxisRegisterError::UnknownAxis(m) => { eprintln!("aura: {m}"); std::process::exit(2) diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 1f1e2d4..f4d489d 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -2449,6 +2449,53 @@ fn aura_walkforward_synthetic_blueprint_refuses_a_wrapped_form_axis_name() { let _ = std::fs::remove_dir_all(&dir); } +/// Property (#331 cycle-close, high finding): the SYNTHETIC (no `--real`) +/// blueprint sweep reads a fresh-FILE envelope and `put_blueprint`s it into +/// the registry unconditionally (`run_blueprint_sweep`, keyed by the +/// family's shared topology hash) — before this fix that write was UNGATED, +/// so `aura sweep bad.json` could plant a shape-violating root name +/// (`"../x"`) in the store, breaking the C29 exemption's premise that "the +/// store is populated through the gated intakes" (`composite_from_any`'s +/// doc comment). A hand-edited envelope whose root name violates +/// `name_gate` must now refuse with the shared `name_gate_fault_prose` +/// wording (byte-identical to `register`/`run`/etc.) at the site's usual +/// exit code (2, matching every other blueprint-load refusal in this +/// function), and nothing must land in `runs/blueprints`. +#[test] +fn aura_sweep_synthetic_blueprint_refuses_a_hand_crafted_envelope_with_a_bad_root_name() { + let dir = temp_cwd("sweep_synthetic_bad_root_name_refusal"); + let fixture_text = std::fs::read_to_string(format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"))) + .expect("read r_sma.json fixture"); + let bad = fixture_text.replacen("\"name\":\"sma_signal\"", "\"name\":\"../x\"", 1); + assert!(bad.contains("\"name\":\"../x\""), "the root-name replace landed: {bad}"); + let file = dir.join("bad-root-name.json"); + std::fs::write(&file, &bad).expect("write envelope fixture"); + + let out = Command::new(BIN) + .args(["sweep", file.to_str().unwrap(), "--axis", "fast.length=2,4"]) + .current_dir(&dir) + .output() + .expect("spawn aura sweep (synthetic, bad root name)"); + assert_eq!( + out.status.code(), + Some(2), + "a shape-violating root name must be refused, not registered: status={:?} stderr={}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + assert!( + stderr.contains("blueprint name \"../x\" is invalid") && stderr.contains("must not contain '/' or '\\'"), + "names the offending root name with the shared gate prose: {stderr}" + ); + assert!(out.stdout.is_empty(), "a refused root name must not print a member line: {out:?}"); + assert!( + !dir.join("runs").exists(), + "a refused root name must leave no store — nothing may land in runs/blueprints" + ); + let _ = std::fs::remove_dir_all(&dir); +} + /// Property (#247, #328 render-seam echo): every sweep-verb boundary /// `BindError` renders as prose in the same register as its unknown-axis /// sibling (`aura: axis : names no param of this blueprint … — see diff --git a/docs/design/contracts/c23-graph-compilation.md b/docs/design/contracts/c23-graph-compilation.md index 2cf2fb3..a7f8515 100644 --- a/docs/design/contracts/c23-graph-compilation.md +++ b/docs/design/contracts/c23-graph-compilation.md @@ -9,7 +9,12 @@ entirely (there is no composite in the flat graph). The blueprint's field / input-role *names* are **non-load-bearing** — the wiring resolves by index, and names survive at most as **informative debug symbols** (exactly as `FieldSpec.name` already is, C8), kept for tracing / rendering (C9 graph-as-data) but carrying no run -semantics. The flat graph is then the target of **behaviour-preserving optimisation** +semantics. (Reconciling with the root composite's own render name, #331: that +claim stands unweakened — the name never influences compilation, identity, or +execution semantics — but at *run* time, outside compilation, it keys the trace +directory (`traces//`); that operational role, not any load-bearing +weight inside the flat graph, is exactly why the authored-blueprint intakes +shape-gate it.) The flat graph is then the target of **behaviour-preserving optimisation** — any transform that leaves every observable sink trace bit-identical (C1 is the correctness invariant) — on two levels: diff --git a/docs/design/contracts/c24-blueprint-data.md b/docs/design/contracts/c24-blueprint-data.md index 7ea6f49..48aca77 100644 --- a/docs/design/contracts/c24-blueprint-data.md +++ b/docs/design/contracts/c24-blueprint-data.md @@ -202,9 +202,14 @@ replayed in order; nodes are referenced **by identifier**, ports as dotted (#331), script-level and at-most-once (a second refuses); omitted, the built composite keeps the CLI's own seed default (`"graph"`). Gated by a shared shape check (non-empty, single path segment, no `/`, `\`, `.` or - `..`) applied at both data-borne birth routes — this op intake and the - blueprint-envelope root name — since store read-back (reproduce, `use` - resolution) stays ungated per C29. + `..`) applied at this op intake **plus every CLI intake that reads an + authored blueprint envelope from a file** — `register`, `introspect + --content-id `, the bare graph-file viewer, `run`, `introspect + --params `, and each of `sweep`/`walkforward`/`mc`/`generalize`'s + file-reading entry points (synthetic and `--real` alike) — one class of + intake, one gate. Store read-back (`reproduce`, `use` resolution, + `introspect`/`--params` by content id) stays deliberately ungated — C29: a + registered artifact is never retroactively invalidated. - `source` — `{"op":"source","role":,"kind":}` — declare a root source role producing a base column of `kind`. - `input` — `{"op":"input","role":}` — declare a root input role (kind inferred