Files
Aura/docs/plans/0104-topology-identity-hash.md
T
Brummel 39cbd44f5b plan: 0104 topology-identity hash
Four tasks: (1) engine identity projection — factor build_doc/serialize_doc
out of blueprint_to_json (byte-preserving, guarded by the canonical golden),
add blueprint_identity_json + strip_debug_symbols + 4 property tests
(renamed twins, openness, bound values, edge swap); (2) CLI --identity-id
sibling flag — composite_from_str factored out of build_from_str, the
exactly-one introspect dispatch deliberately relaxed so the two id flags
combine (one build, both ids, content id first), plus the in-crate
cross-path twin test (Rust sma_signal vs op-script twin: distinct content
ids, one identity id — placed beside the existing cross-surface pins since
sma_signal is private to the bin); (3) four e2e tests incl. the previously
uncovered count!=1 usage path; (4) full regression (873 -> 882 expected).

Plan-recon corrections folded in: the spec sketch's BlueprintData does not
exist — the real DTO is CompositeData; engine re-export + CLI import are a
lockstep pair.

refs #180, refs #171
2026-07-02 20:37:32 +02:00

26 KiB

Topology-identity hash — Implementation Plan

Parent spec: docs/specs/0104-topology-identity-hash.md

For agentic workers: REQUIRED SUB-SKILL: use the implement skill to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Two blueprints expressing the same topology get the same identity hash regardless of authoring path (op-script vs Rust builder, or twins differing only in debug names) — additive beside topology_hash, whose three roles (introspect content id, reproduction store key, reproduce fetch anchor) stay byte-for-byte untouched.

Architecture: One engine-side projection (blueprint_identity_json in blueprint_serde.rs: build the existing BlueprintDoc, blank every non-load-bearing debug symbol, serialize with the existing serializer) and one additive CLI flag (aura graph introspect --identity-id, a sibling of --content-id sharing its input path and the crate::content_id SHA-256 helper). The exactly-one-flag introspect dispatch is deliberately relaxed so the two id flags may combine (both ids, one per line, content id first).

Tech Stack: aura-engine (blueprint_serde.rs, lib.rs re-exports), aura-cli (main.rs clap struct + in-crate tests, graph_construct.rs dispatch), e2e in crates/aura-cli/tests/graph_construct.rs. No new dependencies.

Files this plan creates or modifies:

  • Modify: crates/aura-engine/src/blueprint_serde.rs:100-103 — factor build_doc/serialize_doc out of blueprint_to_json (byte-preserving), add blueprint_identity_json + strip_debug_symbols; property tests in the existing mod tests (anchor :179).
  • Modify: crates/aura-engine/src/lib.rs:60-63 — re-export blueprint_identity_json.
  • Modify: crates/aura-cli/src/graph_construct.rs:9 (import), :109-127 (factor composite_from_str out of build_from_str), :186-245 (introspect_cmd: relaxed dispatch + id branch).
  • Modify: crates/aura-cli/src/main.rs:3817-3819identity_id clap flag on GraphIntrospectCmd; in-crate cross-path twin test after topology_hash_is_the_content_id_of_the_canonical_form (:6013-6016).
  • Test: crates/aura-cli/tests/graph_construct.rs — four new e2e tests (renamed-twin bridging, combined flags, bad-doc rejection, no-flag usage exit 2).

Baselines this plan must leave byte-unchanged: the canonical golden (signal_serializes_to_canonical_golden), every --content-id output, topology_hash, the blueprint store, aura reproduce.


Task 1: Engine — the identity projection

Files:

  • Modify: crates/aura-engine/src/blueprint_serde.rs (fns at :97-103, tests mod at :179)

  • Modify: crates/aura-engine/src/lib.rs:60-63

  • Step 1: Write the failing property tests

In crates/aura-engine/src/blueprint_serde.rs, inside the existing #[cfg(test)] mod tests (starts :179):

First widen two existing import lines at the top of the mod. Change

    use crate::blueprint::{Composite, Role};

to

    use crate::blueprint::{Composite, OutField, Role};

and change

    use aura_std::Recorder;

to

    use aura_std::{Bias, Recorder, Sma, Sub};

Then append, at the end of the mod (after unsupported_version_fails_named), the helper and the four tests:

    // A minimal SMA-cross variant with controllable debug names, boundness, and
    // wiring for the identity-projection property tests — the same 4-node shape
    // as `test_fixtures::sink_free_sma_cross_signal`.
    fn identity_probe(
        comp_name: &str,
        fast_name: &str,
        fast_len: Option<i64>,
        swap_sub_slots: bool,
    ) -> Composite {
        let mut fast = Sma::builder().named(fast_name);
        if let Some(l) = fast_len {
            fast = fast.bind("length", Scalar::i64(l));
        }
        let (fast_slot, slow_slot) = if swap_sub_slots { (1, 0) } else { (0, 1) };
        Composite::new(
            comp_name,
            vec![
                fast.into(),
                Sma::builder().named("slow").into(),
                Sub::builder().into(),
                Bias::builder().into(),
            ],
            vec![
                Edge { from: 0, to: 2, slot: fast_slot, from_field: 0 },
                Edge { from: 1, to: 2, slot: slow_slot, from_field: 0 },
                Edge { from: 2, to: 3, slot: 0, from_field: 0 },
            ],
            vec![Role {
                name: "price".into(),
                targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
                source: None,
            }],
            vec![OutField { node: 3, field: 0, name: "bias".into() }],
        )
    }

    /// #171 acc 1 (engine layer): twins identical up to debug names (composite
    /// name, instance name) share the identity JSON while their canonical JSON
    /// differs.
    #[test]
    fn renamed_twins_share_identity_json_not_canonical_json() {
        let a = identity_probe("sma_cross", "fast", Some(2), false);
        let b = identity_probe("renamed", "speedy", Some(2), false);
        assert_ne!(
            blueprint_to_json(&a).expect("serializes"),
            blueprint_to_json(&b).expect("serializes"),
            "canonical bytes keep the debug names apart"
        );
        let ia = blueprint_identity_json(&a).expect("identity-serializes");
        let ib = blueprint_identity_json(&b).expect("identity-serializes");
        assert_eq!(ia, ib, "identity form is debug-name-blind");
        assert!(!ia.contains("speedy") && !ia.contains("sma_cross"), "no debug symbol leaks: {ia}");
    }

    /// #171 acc 2 (openness is identity-bearing): a blueprint with `fast.length`
    /// bound and its open twin never share an identity JSON.
    #[test]
    fn open_vs_bound_param_changes_identity() {
        let bound = identity_probe("x", "fast", Some(2), false);
        let open = identity_probe("x", "fast", None, false);
        assert_ne!(
            blueprint_identity_json(&bound).expect("identity-serializes"),
            blueprint_identity_json(&open).expect("identity-serializes"),
            "boundness survives the identity projection"
        );
    }

    /// #171 acc 2 (bound values are identity-bearing): value twins differing in
    /// one bound value (I64:2 vs I64:3) never share an identity JSON.
    #[test]
    fn bound_value_changes_identity() {
        assert_ne!(
            blueprint_identity_json(&identity_probe("x", "fast", Some(2), false))
                .expect("identity-serializes"),
            blueprint_identity_json(&identity_probe("x", "fast", Some(3), false))
                .expect("identity-serializes"),
            "a bound value survives the identity projection"
        );
    }

    /// Wiring is identity-bearing: swapping the two Sub input slots (fast->rhs,
    /// slow->lhs) is a different topology, hence a different identity JSON.
    #[test]
    fn edge_swap_changes_identity() {
        assert_ne!(
            blueprint_identity_json(&identity_probe("x", "fast", Some(2), false))
                .expect("identity-serializes"),
            blueprint_identity_json(&identity_probe("x", "fast", Some(2), true))
                .expect("identity-serializes"),
            "edge slots survive the identity projection"
        );
    }

(Edge, Target, Scalar are already imported by the mod; Composite::new takes the same argument shapes as test_fixtures::sink_free_sma_cross_signal.)

  • Step 2: Run the tests to verify they fail

Run: cargo test -p aura-engine identity Expected: COMPILE FAILURE — error[E0425]: cannot find function blueprint_identity_json`` (the RED for this task; the fn does not exist yet).

  • Step 3: Implement the projection (factoring + new fns + re-export)

In crates/aura-engine/src/blueprint_serde.rs, replace the current blueprint_to_json (:97-103):

/// Serialize a blueprint to canonical, versioned JSON: compact, struct-declaration
/// field order, defaults omitted (`skip_serializing_if`). An absent optional is
/// byte-identical to the pre-extension form.
pub fn blueprint_to_json(c: &Composite) -> Result<String, SerializeError> {
    let doc = BlueprintDoc { format_version: BLUEPRINT_FORMAT_VERSION, blueprint: project(c) };
    serde_json::to_string(&doc).map_err(SerializeError::Json)
}

with the factored trio plus the identity pair:

fn build_doc(c: &Composite) -> BlueprintDoc {
    BlueprintDoc { format_version: BLUEPRINT_FORMAT_VERSION, blueprint: project(c) }
}

fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
    serde_json::to_string(doc).map_err(SerializeError::Json)
}

/// Serialize a blueprint to canonical, versioned JSON: compact, struct-declaration
/// field order, defaults omitted (`skip_serializing_if`). An absent optional is
/// byte-identical to the pre-extension form.
pub fn blueprint_to_json(c: &Composite) -> Result<String, SerializeError> {
    serialize_doc(&build_doc(c))
}

/// The identity-canonical form (#171): the canonical document with every
/// non-load-bearing debug symbol (C23) blanked — composite name, instance names,
/// bound-param names, role names, output re-export names. Everything load-bearing
/// survives: type ids, node order, edges, role targets/order, output pairs/order,
/// and bound positions/kinds/values (param openness stays identity-bearing: a
/// bound slot is textually present, an open one absent). Research-side comparison
/// form ONLY — never a load path and never the reproduction store's byte form
/// (`reproduce` re-binds params by name, so instance names are load-bearing there).
pub fn blueprint_identity_json(c: &Composite) -> Result<String, SerializeError> {
    let mut doc = build_doc(c);
    strip_debug_symbols(&mut doc.blueprint);
    serialize_doc(&doc)
}

fn strip_debug_symbols(b: &mut CompositeData) {
    b.name = String::new();
    for node in &mut b.nodes {
        match node {
            NodeData::Primitive(p) => {
                p.name = None;
                for bp in &mut p.bound {
                    bp.name = String::new(); // pos/kind/value survive
                }
            }
            NodeData::Composite(c) => strip_debug_symbols(c),
        }
    }
    for role in &mut b.input_roles {
        role.name = String::new(); // targets + order survive
    }
    for out in &mut b.output {
        out.name = String::new(); // (node, field) + order survive
    }
}

In crates/aura-engine/src/lib.rs, extend the blueprint_serde re-export block (:60-63) — change

pub use blueprint_serde::{
    blueprint_from_json, blueprint_to_json, BlueprintDoc, CompositeData, LoadError, NodeData,
    PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
};

to

pub use blueprint_serde::{
    blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintDoc, CompositeData,
    LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
};
  • Step 4: Run the new tests to verify they pass

Run: cargo test -p aura-engine identity Expected: PASS — 4 passed (renamed_twins_share_identity_json_not_canonical_json, open_vs_bound_param_changes_identity, bound_value_changes_identity, edge_swap_changes_identity), 0 failed.

  • Step 5: Verify the factoring is byte-preserving (golden + serde suite)

Run: cargo test -p aura-engine blueprint_serde Expected: PASS — all blueprint_serde tests green, including signal_serializes_to_canonical_golden byte-identical (10 passed = 6 existing + 4 new in this module's filter scope), 0 failed.


Task 2: CLI — the --identity-id sibling flag and the cross-path twin test

Files:

  • Modify: crates/aura-cli/src/main.rs:3817-3819 (clap field), tests mod after :6016 (const + test)

  • Modify: crates/aura-cli/src/graph_construct.rs:9, :109-127, :186-245

  • Step 1: Add the clap flag

In crates/aura-cli/src/main.rs, in struct GraphIntrospectCmd (:3806-3820), after the content_id field

    /// Print the graph's content id (topology hash).
    #[arg(long)]
    content_id: bool,

append:

    /// Print the graph's topology-identity id (debug names stripped).
    #[arg(long)]
    identity_id: bool,
  • Step 2: Factor composite_from_str and rework the id branch

In crates/aura-cli/src/graph_construct.rs:

(a) Change the import at :9 from

use aura_engine::{blueprint_to_json, replay, BindOpError, GraphSession, Op, OpError, Scalar, ScalarKind};

to

use aura_engine::{
    blueprint_identity_json, blueprint_to_json, replay, BindOpError, Composite, GraphSession, Op,
    OpError, Scalar, ScalarKind,
};

(b) Replace build_from_str (:109-127) with the factored pair (behaviour-preserving: same fault strings, same success bytes):

/// Parse a JSON op-list document and replay it through the env's vocabulary into
/// a built `Composite` — or a `op N (kind): cause` message (a per-op fault,
/// attributed by the retained op-kind list) / `finalize: cause` (a holistic fault
/// past the last op).
fn composite_from_str(doc: &str, env: &crate::project::Env) -> Result<Composite, String> {
    let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
    let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect();
    let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
    replay("graph", ops, &|t| env.resolve(t)).map_err(|(idx, err)| {
        let cause = format_op_error(&err);
        match labels.get(idx) {
            Some(kind) => format!("op {idx} ({kind}): {cause}"),
            None => format!("finalize: {cause}"),
        }
    })
}

/// Parse a JSON op-list document, replay it through the env's vocabulary, and
/// return the emitted #155 blueprint JSON — fault shapes as in `composite_from_str`.
pub fn build_from_str(doc: &str, env: &crate::project::Env) -> Result<String, String> {
    let composite = composite_from_str(doc, env)?;
    blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}"))
}

(c) In introspect_cmd (:186-245): update the doc comment and the dispatch — replace

/// `aura graph introspect`: dispatch the read-only queries. Exactly one of
/// `--vocabulary` / `--node <T>` / `--unwired` / `--content-id` must be set;
/// zero or more than one is the usage error (exit 2).
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env) {
    let count = cmd.vocabulary as usize
        + cmd.node.is_some() as usize
        + cmd.unwired as usize
        + cmd.content_id as usize;
    if count != 1 {
        eprintln!(
            "aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --content-id"
        );
        std::process::exit(2);
    }

with

/// `aura graph introspect`: dispatch the read-only queries. Exactly one of
/// `--vocabulary` / `--node <T>` / `--unwired` / the id group must be set; zero
/// or more than one is the usage error (exit 2). The id group is `--content-id`
/// and/or `--identity-id` — the two id flags may combine (one build, both ids,
/// content id first).
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env) {
    let count = cmd.vocabulary as usize
        + cmd.node.is_some() as usize
        + cmd.unwired as usize
        + (cmd.content_id || cmd.identity_id) as usize;
    if count != 1 {
        eprintln!(
            "aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --content-id | --identity-id (the two id flags may be combined)"
        );
        std::process::exit(2);
    }

and replace the final else branch (:226-244, the --content-id arm)

    } else {
        // --content-id: the content id (#158) of the op-list's canonical blueprint:
        // SHA256 (hex) of the same `blueprint_to_json` bytes `graph build` emits, via
        // the one shared `crate::content_id` primitive `topology_hash` also uses — so
        // this surface and hashing a `graph build` output agree by construction.
        use std::io::Read;
        let mut doc = String::new();
        if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
            eprintln!("aura: reading stdin: {e}");
            std::process::exit(1);
        }
        match build_from_str(&doc, env) {
            Ok(json) => println!("{}", crate::content_id(&json)),
            Err(m) => {
                eprintln!("aura: {m}");
                std::process::exit(1);
            }
        }
    }

with

    } else {
        // --content-id / --identity-id (combinable): one build of the op-list, then
        // each requested id on its own line, content id first. The content id (#158)
        // is the SHA256 of the same `blueprint_to_json` bytes `graph build` emits;
        // the identity id (#171) the SHA256 of the debug-name-blind
        // `blueprint_identity_json` form — both via the one shared
        // `crate::content_id` primitive `topology_hash` also uses, so all surfaces
        // agree by construction.
        use std::io::Read;
        let mut doc = String::new();
        if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
            eprintln!("aura: reading stdin: {e}");
            std::process::exit(1);
        }
        let composite = match composite_from_str(&doc, env) {
            Ok(c) => c,
            Err(m) => {
                eprintln!("aura: {m}");
                std::process::exit(1);
            }
        };
        if cmd.content_id {
            match blueprint_to_json(&composite) {
                Ok(json) => println!("{}", crate::content_id(&json)),
                Err(e) => {
                    eprintln!("aura: serialize error: {e:?}");
                    std::process::exit(1);
                }
            }
        }
        if cmd.identity_id {
            match blueprint_identity_json(&composite) {
                Ok(json) => println!("{}", crate::content_id(&json)),
                Err(e) => {
                    eprintln!("aura: serialize error: {e:?}");
                    std::process::exit(1);
                }
            }
        }
    }

(For --content-id alone this is byte- and exit-code-identical to the old path: same success line, same aura: op N (kind): cause / aura: serialize error: ... faults, same exit 1.)

  • Step 3: Build

Run: cargo build -p aura-cli Expected: clean build, 0 errors.

  • Step 4: Write the in-crate cross-path twin test

In crates/aura-cli/src/main.rs, in the same #[cfg(test)] tests module that holds content_id_is_stable_across_the_store_round_trip (:5976), immediately after topology_hash_is_the_content_id_of_the_canonical_form (:6013-6016), insert:

    /// The op-script twin of `sma_signal(Some(2), Some(4))`: same topology —
    /// SMA(2)/SMA(4) over one `price` role, spread, Bias with `scale` BOUND to
    /// 0.5 (= `R_SMA_BIAS_SCALE`; boundness is identity-bearing) — differing
    /// only in debug names (composite "graph" vs "sma_signal", unnamed Bias vs
    /// `.named("bias")`).
    const IDENTITY_TWIN_DOC: &str = r#"[
        {"op":"source","role":"price","kind":"F64"},
        {"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
        {"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
        {"op":"add","type":"Sub"},
        {"op":"add","type":"Bias","bind":{"scale":{"F64":0.5}}},
        {"op":"feed","role":"price","into":["fast.series","slow.series"]},
        {"op":"connect","from":"fast.value","to":"sub.lhs"},
        {"op":"connect","from":"slow.value","to":"sub.rhs"},
        {"op":"connect","from":"sub.value","to":"bias.signal"},
        {"op":"expose","from":"bias.bias","as":"bias"}
    ]"#;

    /// #171 acc 1 (cross-path identity): the Rust `sma_signal` builder and its
    /// op-script twin differ in canonical bytes (debug names) — distinct content
    /// ids — but project to the same identity JSON, hence one identity id across
    /// authoring paths.
    #[test]
    fn identity_id_bridges_the_rust_builder_and_op_script_paths() {
        let rust_built = sma_signal(Some(2), Some(4));
        let env = project::Env::std();
        let json = crate::graph_construct::build_from_str(IDENTITY_TWIN_DOC, &env)
            .expect("op-script twin builds");
        assert_ne!(
            content_id(&json),
            topology_hash(&rust_built),
            "authoring paths keep distinct content ids"
        );
        let loaded = blueprint_from_json(&json, &|t| std_vocabulary(t)).expect("twin reloads");
        let identity = |c: &Composite| {
            content_id(&aura_engine::blueprint_identity_json(c).expect("identity-serializes"))
        };
        assert_eq!(
            identity(&loaded),
            identity(&rust_built),
            "one topology -> one identity id across authoring paths"
        );
    }

Note: this test is expected GREEN on first run — it pins the cross-path property using Task 1's engine projection over this crate's fixtures (its RED partner was Task 1 Step 2's compile failure). Its role is the #171 acceptance-1 evidence and a regression guard, not this task's RED.

  • Step 5: Run the new and neighbouring in-crate tests

Run: cargo test -p aura-cli --bin aura identity Expected: PASS — 1 passed (identity_id_bridges_the_rust_builder_and_op_script_paths), 0 failed.

Run: cargo test -p aura-cli --bin aura content_id Expected: PASS — 3 passed (content_id_is_stable_across_the_store_round_trip, content_id_is_stable_across_a_tolerated_tier1_field, topology_hash_is_the_content_id_of_the_canonical_form), 0 failed.


Task 3: E2E — the --identity-id binary contract

Files:

  • Test: crates/aura-cli/tests/graph_construct.rs (append after graph_introspect_unknown_node_flag_is_usage_exit_2)

  • Step 1: Write the four e2e tests

Append to crates/aura-cli/tests/graph_construct.rs:

/// Property (#171 acc 1 at the binary seam): `--identity-id` bridges op-scripts
/// that differ only in debug names — the renamed twin keeps the same identity id
/// while its content id moves. Rename via blanket replace: "fast" occurs as the
/// instance name and in every `fast.*` reference, so the replaced document stays
/// internally consistent.
#[test]
fn graph_introspect_identity_id_bridges_renamed_op_scripts() {
    let renamed = SIGNAL_DOC.replace("fast", "speedy");
    let (ia, _e, ok) = run(&["graph", "introspect", "--identity-id"], SIGNAL_DOC);
    assert!(ok, "exit success");
    let ia = ia.trim().to_string();
    assert_eq!(ia.len(), 64, "a 64-hex identity id: {ia:?}");
    assert!(ia.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {ia:?}");
    let (ib, _e2, ok2) = run(&["graph", "introspect", "--identity-id"], &renamed);
    assert!(ok2, "exit success");
    assert_eq!(ia, ib.trim(), "renamed twin -> same identity id");
    let (ca, _e3, ok3) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
    let (cb, _e4, ok4) = run(&["graph", "introspect", "--content-id"], &renamed);
    assert!(ok3 && ok4, "exit success");
    assert_ne!(ca.trim(), cb.trim(), "renamed twin -> different content ids");
}

/// Property (combinable id flags): `--content-id --identity-id` prints both ids,
/// one per line, content id first — each byte-equal to its single-flag output,
/// and the two differ (SIGNAL_DOC carries debug names the identity form blanks).
#[test]
fn graph_introspect_content_and_identity_id_combine() {
    let (both, _e, ok) = run(&["graph", "introspect", "--content-id", "--identity-id"], SIGNAL_DOC);
    assert!(ok, "exit success");
    let lines: Vec<&str> = both.lines().collect();
    assert_eq!(lines.len(), 2, "two lines, one id each: {both:?}");
    let (content, _e2, ok2) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
    let (identity, _e3, ok3) = run(&["graph", "introspect", "--identity-id"], SIGNAL_DOC);
    assert!(ok2 && ok3, "exit success");
    assert_eq!(lines[0], content.trim(), "content id first");
    assert_eq!(lines[1], identity.trim(), "identity id second");
    assert_ne!(lines[0], lines[1], "the two ids differ on a debug-named document");
}

/// Property (negative, mirror of the content-id rejection): `--identity-id` on a
/// bad op-list exits non-zero with the cause on stderr and prints no id.
#[test]
fn graph_introspect_identity_id_rejects_a_bad_document() {
    let (stdout, stderr, ok) =
        run(&["graph", "introspect", "--identity-id"], r#"[{"op":"add","type":"Nope"}]"#);
    assert!(!ok, "non-zero exit on a bad op-list");
    assert!(stdout.is_empty(), "no identity id emitted on error: {stdout}");
    assert!(stderr.contains("Nope"), "names the cause: {stderr}");
}

/// Property (usage gate — the previously untested count!=1 dispatch path):
/// `graph introspect` with no selection flag is a usage fault — exit 2 with the
/// usage line on stderr.
#[test]
fn graph_introspect_no_flag_is_usage_exit_2() {
    let (stderr, code) = run_code(&["graph", "introspect"], "");
    assert_eq!(code, Some(2), "no selection flag is a usage error -> exit 2; stderr: {stderr}");
    assert!(stderr.contains("Usage"), "prints the usage line: {stderr}");
}
  • Step 2: Run the e2e target

Run: cargo test -p aura-cli --test graph_construct Expected: PASS — 20 passed (16 existing + 4 new), 0 failed. In particular graph_introspect_content_id_is_deterministic_and_distinguishes and graph_introspect_content_id_rejects_a_bad_document stay green (the --content-id byte-unchanged pin, spec acceptance 3).


Task 4: Full regression

Files: none (verification only)

  • Step 1: Build

Run: cargo build --workspace Expected: clean, 0 errors.

  • Step 2: Full suite

Run: cargo test --workspace Expected: 0 failed across all targets; total grows by exactly the 9 new tests vs the cycle-0103 baseline of 873 (= 882 passed). Reproduce, store, goldens all untouched (spec acceptance 3).

  • Step 3: Lint

Run: cargo clippy --workspace --all-targets -- -D warnings Expected: clean, 0 warnings.

  • Step 4: Doc build

Run: cargo doc --workspace --no-deps 2>&1 Expected: finishes with 0 warnings.