audit: trace-identity cycle close — C29 honoured, code twins back in lockstep

The drift review found the run handle keying on the blueprint's *content* id,
which hashes `doc` fields and every other C23 debug symbol. C29's Id treatment
forbids a description influencing an identity, and the repository already
carried the right projection unused. The digest now substitutes
`blueprint_identity_json`'s debug-symbol-blind projection (#171) for
`topology_hash` in the hashed value. `manifest.topology_hash` itself is
untouched, so #343's reference semantics and the committed record-line
fingerprint both stand.

That projection blanks more than descriptions — the render name and node, role,
output, tap and gang names — while param openness stays identity-bearing. This
is correct rather than incidental: names are non-load-bearing debug symbols
(C8/C23), so blueprints differing only in them compute bit-identically and one
address is the right answer. The reach is now stated in C22 and in the digest's
own doc, together with the one observable consequence: two structurally
identical blueprints whose declared tap names differ share a directory while
writing differently named tap files, since the write path never prunes (#352).

Two code twins the cycle left stale are corrected. `name_gate`'s doc comment
still carried the C23 clause this cycle superseded and moved to its sidecar,
and the authoring guide claimed a same-identity re-run replaces the directory's
contents — it does not, for the same non-pruning reason.

Bench is report-only and all five fingerprints are unchanged. `campaign_sweep`
reports a peak-RSS NOTICE (+12.8%); the campaign leg is untouched by this cycle
and its fingerprint is stable, so nothing is ratified against it here.

Routed rather than fixed: #353 — naming authority for one flat namespace is
spelled in three crates and the single-run leg has no claim path — filed into
the recorded-stream-store milestone, where the container consolidates anyway; a
data-identity seam recorded on #320 (a run's identity carries the window, never
the data's content, so a corrected archive is a silent overwrite until that
store supplies a recording identity); and a second instance of the non-pruning
class on #352.

The cycle's spec and plan are removed — git-ignored working files, read by the
drift review before deletion.

refs #311
This commit is contained in:
2026-07-27 11:21:22 +02:00
parent b18a695531
commit 0d6d5b1324
12 changed files with 254 additions and 51 deletions
+14 -3
View File
@@ -19,7 +19,7 @@ use aura_core::Cell;
#[cfg(test)]
use aura_composites::StopRule;
use aura_engine::{
blueprint_from_json, blueprint_to_json, f64_field, join_on_ts,
blueprint_from_json, blueprint_identity_json, blueprint_to_json, f64_field, join_on_ts,
JoinedRow,
SelectionMode,
};
@@ -1447,13 +1447,21 @@ fn exec_blueprint_leg(path: &str, overrides: &[String], tap: &[String], env: &au
// `&cell.strategy_id`, the stored base document's id, as `topo` even
// for a reopened member). Computed here, over the still-unreopened
// `signal`, before `reopen_all` consumes it, and threaded into
// `run_signal_r`'s own `topo` parameter so every consumer inside that
// `run_signal_r`'s own `refs` parameter so every consumer inside that
// function — the record line AND the trace-store persistence
// (`bound.finish`, `aura-registry::trace_store`) — sees the base
// document's hash from the start; no post-hoc manifest mutation
// after the run, which would miss the already-persisted `index.json`.
// `base_identity` is the sibling #171 identity projection, computed
// over the SAME un-reopened `signal` for the same reason (2026-07-27,
// C29): the reopened variant would show the overridden param as OPEN
// rather than bound, which would split a no-op `--override` from the
// un-overridden run it is identical to (`run_identity_digest`'s doc
// comment).
let base_topo =
content_id(&blueprint_to_json(&signal).expect("a buildable signal serializes"));
let base_identity =
content_id(&blueprint_identity_json(&signal).expect("a buildable signal serializes"));
let signal = reopen_all(signal, &paths);
// An out-of-domain override value fails a node's own domain check at
// bootstrap (e.g. `Sma::new`'s `assert!`, or a negative length
@@ -1466,7 +1474,10 @@ fn exec_blueprint_leg(path: &str, overrides: &[String], tap: &[String], env: &au
// refusal (exit 1, C14 partition) instead of an uncaught exit 101 —
// the input was well-formed argv; the value is what the node refuses.
let outcome = match aura_campaign::catch_member_panic(|| {
run_signal_r(signal, &params, RunData::Synthetic, 0, env, tap_plan, Some(&base_topo))
run_signal_r(
signal, &params, RunData::Synthetic, 0, env, tap_plan,
Some((&base_topo, &base_identity)),
)
})
.unwrap_or_else(|msg| {
eprintln!("aura: {}", panic_refusal_prose(&msg));
+63
View File
@@ -721,6 +721,69 @@ same effective params must mint the same handle: base {base_out} / overridden {o
);
}
/// C29 audit fix (2026-07-27): a blueprint's composite-level `doc` field is a
/// CONTENT-id key (`blueprint_to_json` serializes it, so it changes
/// `topology_hash`) but an identity-BLIND debug symbol
/// (`blueprint_identity_json`/`strip_debug_symbols` blanks it) — C29's own Id
/// treatment: "description fields … are blanked for the identity id". Two
/// blueprints differing ONLY in that field are, by that rule, the SAME run and
/// must land in the SAME trace directory, even though their `topology_hash`es
/// genuinely differ (proving the two documents are not byte-identical, so this
/// is not a vacuous pass).
#[test]
fn exec_blueprint_description_only_edit_lands_in_the_same_trace_directory() {
let cwd = temp_cwd("doc-only-same-dir");
let bare_path = cwd.join("bare.json");
std::fs::write(&bare_path, tap_blueprint_json()).expect("write bare blueprint");
let mut with_doc: serde_json::Value =
serde_json::from_str(&tap_blueprint_json()).expect("parse tapped blueprint");
with_doc["blueprint"]["doc"] = serde_json::json!("an authored one-line rationale");
let doc_path = cwd.join("with_doc.json");
std::fs::write(&doc_path, serde_json::to_string(&with_doc).expect("re-serialize doc'd blueprint"))
.expect("write doc'd blueprint");
let (base_out, base_code) =
run_code_in(&cwd, &["exec", bare_path.to_str().expect("utf-8 path")]);
assert_eq!(base_code, Some(0), "stdout/stderr: {base_out}");
let base_line = base_out.lines().find(|l| l.starts_with('{')).expect("record line");
let base_v: serde_json::Value = serde_json::from_str(base_line).expect("record parses");
let base_handle =
base_v["trace_name"].as_str().expect("a recording run prints its trace_name").to_string();
let base_topo =
base_v["manifest"]["topology_hash"].as_str().expect("base topology_hash").to_string();
let (doc_out, doc_code) =
run_code_in(&cwd, &["exec", doc_path.to_str().expect("utf-8 path")]);
assert_eq!(doc_code, Some(0), "stdout/stderr: {doc_out}");
let doc_line = doc_out.lines().find(|l| l.starts_with('{')).expect("record line");
let doc_v: serde_json::Value = serde_json::from_str(doc_line).expect("record parses");
let doc_handle =
doc_v["trace_name"].as_str().expect("a recording run prints its trace_name").to_string();
let doc_topo =
doc_v["manifest"]["topology_hash"].as_str().expect("doc'd topology_hash").to_string();
assert_ne!(
base_topo, doc_topo,
"a doc edit DOES change the content id (C24/C29) — the two documents are not \
byte-identical: {base_out} / {doc_out}"
);
assert_eq!(
base_handle, doc_handle,
"a description-only edit must not mint a new trace directory (C29's Id treatment): \
base {base_out} / with-doc {doc_out}"
);
let entries: Vec<_> = std::fs::read_dir(cwd.join("runs/traces"))
.expect("read traces dir")
.map(|e| e.expect("dir entry").file_name())
.collect();
assert_eq!(
entries.len(),
1,
"one identity, one directory — found {entries:?} under runs/traces"
);
}
/// A malformed override token (no `NODE.PARAM=VALUE` shape) refuses as usage.
#[test]
fn exec_override_malformed_token_refuses_with_usage() {
+4 -2
View File
@@ -198,8 +198,10 @@ pub struct GangMember {
/// Deterministic shape gate for authored composite render names (#331) —
/// shape only, never content judgement (C29 discipline): non-empty, no path
/// separator (`/` or `\`), and not the special segments `.` or `..`. The rule
/// exists because the name keys a trace directory on disk unsanitized
/// (`traces/<name>/`, `crates/aura-registry/src/trace_store.rs`). Applied at
/// exists because the name has an operational run-time role: it prefixes a
/// run's identity-keyed trace directory on disk unsanitized
/// (`traces/<name>-<id8>/`, #311; `crates/aura-registry/src/trace_store.rs`),
/// not the whole directory component as before #311. Applied at
/// both data-borne birth routes for a name (the skeptic's two-seam finding):
/// the `Op::Name` op intake (`construction.rs`) and the CLI's
/// blueprint-envelope root-name intake — never at store read-back (C29:
+80 -18
View File
@@ -101,7 +101,33 @@ pub struct RunnerError {
/// serde_json's map is sorted, so re-serialising a parsed value yields
/// deterministic bytes — the same technique aura-bench uses for its record-line
/// fingerprint (`crates/aura-bench/src/surfaces/fixed_cost.rs`).
pub fn run_identity_digest(manifest: &aura_engine::RunManifest) -> String {
///
/// **`topology_hash` is not hashed as-is — `identity_hash` stands in for it
/// (2026-07-27 audit fix, C29).** `manifest.topology_hash` is a **content** id
/// (`content_id_of(blueprint_to_json(..))`, #158/C24): the canonical bytes it
/// hashes include a blueprint's `doc` field — an authored one-line rationale,
/// C29 — and every other C23 debug symbol (render name, instance names, bound
/// param/role/output/tap/gang names). A description-only (or name-only) edit
/// to an otherwise bit-identical blueprint therefore changes `topology_hash`,
/// which would mint a fresh trace directory for what is, by C29's own Id
/// treatment ("description fields … are blanked for the identity id"), the
/// SAME run — a description influencing an identity id is exactly what C29
/// forbids. The caller passes `identity_hash` — the #171
/// `blueprint_identity_json` projection (debug-symbol-blind by construction)
/// — and this function substitutes it for whatever `manifest.topology_hash`
/// carries in the hashed value; `manifest.topology_hash` itself is untouched
/// (it keeps its own #343 reference-semantics content id — the reproduction
/// store still keys on it byte-exact). **The blanking is wholesale, not
/// description-only:** `identity_hash` is blind to the render name and every
/// node/role/output/tap/gang name too, because none of these carry run
/// semantics (a bound param's *openness* stays identity-bearing) — two
/// blueprints identical in every load-bearing respect but differing only in
/// such names correctly compute the same `identity_hash`, one directory, not
/// two. Both mint sites
/// (`aura-runner::member::run_signal_r`, `aura-runner::measure::run_measurement`)
/// compute `identity_hash` from the SAME blueprint reference they already hash
/// for `topology_hash`, so the two can never drift.
pub fn run_identity_digest(manifest: &aura_engine::RunManifest, identity_hash: &str) -> String {
use aura_engine::Scalar;
use sha2::{Digest, Sha256};
@@ -124,6 +150,10 @@ pub fn run_identity_digest(manifest: &aura_engine::RunManifest) -> String {
"params".to_string(),
serde_json::to_value(&merged).expect("the merged param vec serializes"),
);
obj.insert(
"topology_hash".to_string(),
serde_json::Value::String(identity_hash.to_string()),
);
}
let canonical = serde_json::to_string(&v).expect("re-serializing a parsed value cannot fail");
let hex = format!("{:x}", Sha256::digest(canonical.as_bytes()));
@@ -135,6 +165,15 @@ mod tests {
use super::run_identity_digest;
use aura_engine::{ProjectProvenance, RunManifest, Scalar, Timestamp};
/// A fixed `identity_hash` stand-in used by every case below that does not
/// itself vary the blueprint's identity projection — held equal across both
/// calls being compared so the case tests exactly the field it names, not an
/// incidental `identity_hash` difference. Shaped like a real SHA-256 hex
/// digest (64 lowercase hex chars) but not a real hash of anything.
fn identity_hash() -> String {
"beef".repeat(16)
}
/// A manifest with every field populated the way a real single run stamps
/// them — the base each case below perturbs by exactly one field.
fn manifest() -> RunManifest {
@@ -158,7 +197,7 @@ mod tests {
#[test]
fn run_identity_digest_is_eight_lowercase_hex() {
let d = run_identity_digest(&manifest());
let d = run_identity_digest(&manifest(), &identity_hash());
assert_eq!(d.len(), 8, "the handle suffix is 8 hex chars: {d}");
assert!(
d.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()),
@@ -172,8 +211,8 @@ mod tests {
let mut b = manifest();
b.commit = "0123456789abcdef".to_string();
assert_eq!(
run_identity_digest(&a),
run_identity_digest(&b),
run_identity_digest(&a, &identity_hash()),
run_identity_digest(&b, &identity_hash()),
"manifest.commit is the binary's build provenance, not the run's identity"
);
}
@@ -185,8 +224,8 @@ mod tests {
b.project.as_mut().expect("base manifest carries provenance").commit =
Some("deadbeef-dirty".to_string());
assert_eq!(
run_identity_digest(&a),
run_identity_digest(&b),
run_identity_digest(&a, &identity_hash()),
run_identity_digest(&b, &identity_hash()),
"project.commit is re-derived per invocation; editing the worktree is not a new run"
);
}
@@ -200,8 +239,8 @@ mod tests {
let mut b = manifest();
b.project.as_mut().expect("base manifest carries provenance").commit = None;
assert_eq!(
run_identity_digest(&a),
run_identity_digest(&b),
run_identity_digest(&a, &identity_hash()),
run_identity_digest(&b, &identity_hash()),
"a derivable HEAD and an underivable one are the same run"
);
}
@@ -212,8 +251,8 @@ mod tests {
let mut b = manifest();
b.params = vec![("fast.length".to_string(), Scalar::I64(3))];
assert_ne!(
run_identity_digest(&a),
run_identity_digest(&b),
run_identity_digest(&a, &identity_hash()),
run_identity_digest(&b, &identity_hash()),
"params are identity-bearing — this is the headline case"
);
}
@@ -233,8 +272,8 @@ mod tests {
("bias.scale".to_string(), Scalar::F64(0.5)),
];
assert_eq!(
run_identity_digest(&a),
run_identity_digest(&b),
run_identity_digest(&a, &identity_hash()),
run_identity_digest(&b, &identity_hash()),
"same effective params, different params/defaults partition — same run"
);
}
@@ -248,17 +287,40 @@ mod tests {
b.project.as_mut().expect("base manifest carries provenance").dylib_sha256 =
Some("ff00".to_string());
assert_ne!(
run_identity_digest(&a),
run_identity_digest(&b),
run_identity_digest(&a, &identity_hash()),
run_identity_digest(&b, &identity_hash()),
"dylib_sha256 is exactly what C13 hot-reload varies"
);
}
/// C29 audit fix (2026-07-27): `topology_hash` is a CONTENT id — it hashes
/// a blueprint's `doc` field (and every other C23 debug symbol) along with
/// its structure — so a description-only edit changes `manifest.
/// topology_hash` even though the run is otherwise bit-identical. The
/// digest must not tell these two manifests apart: it hashes the caller's
/// `identity_hash`, not `manifest.topology_hash`, so two manifests whose
/// `topology_hash` fields differ (as a real doc-only edit would produce)
/// digest the same as long as the SAME `identity_hash` is supplied — which
/// is exactly what `blueprint_identity_json` (doc-blind) computes for both.
#[test]
fn run_identity_digest_ignores_topology_hash_content_id_when_identity_hash_agrees() {
let a = manifest(); // topology_hash: "0f1e2d3c"
let mut b = manifest();
b.topology_hash = Some("ffffffff".to_string()); // as if a doc-only edit changed the content id
assert_eq!(
run_identity_digest(&a, &identity_hash()),
run_identity_digest(&b, &identity_hash()),
"a description-only edit changes topology_hash (a content id, C29) but not \
the blueprint's identity projection — the digest must not tell these apart"
);
}
/// The absolute property every other case in this module only tests
/// relationally: ONE fully-populated manifest (every field set, including
/// a `project` with `namespace`, `dylib_sha256` AND `commit` all present)
/// yields this ONE specific 8-hex value — stably, across builds and
/// processes, since the whole trace store's addressing rests on it.
/// plus one fixed `identity_hash` yields this ONE specific 8-hex value —
/// stably, across builds and processes, since the whole trace store's
/// addressing rests on it.
///
/// If this literal ever needs to change, that change is NOT a casual
/// update: it means `run_identity_digest`'s canonicalisation shifted
@@ -285,8 +347,8 @@ mod tests {
}),
};
assert_eq!(
run_identity_digest(&m),
"3d080714",
run_identity_digest(&m, &"c0ffee00".repeat(8)),
"8cfcc5fc",
"the digest for this fixed manifest must be stable across builds and processes"
);
}
+8 -2
View File
@@ -90,10 +90,15 @@ pub fn run_measurement(
// topology_hash's own two-line body, inlined (mirrors member::run_signal_r):
// `content_id_of` over the canonical (#164) blueprint JSON — the CLI shell's
// `topology_hash` helper is the same primitive, kept single-sourced at
// `aura_research`.
// `aura_research`. `identity_hash` is the sibling #171 projection over the
// SAME `signal`, feeding `run_identity_digest` in place of `topology_hash`
// (2026-07-27, C29) — see that function's doc comment.
let topo = aura_research::content_id_of(
&aura_engine::blueprint_to_json(&signal).expect("a buildable signal serializes"),
); // before signal is consumed
let identity_hash = aura_research::content_id_of(
&aura_engine::blueprint_identity_json(&signal).expect("a buildable signal serializes"),
); // before signal is consumed
// #311 (mirrors run_signal_r): the render name is the readable prefix; the
// directory is minted from the manifest below.
let render_name = signal.name().to_string();
@@ -131,7 +136,8 @@ pub fn run_measurement(
manifest.defaults = defaults;
manifest.topology_hash = Some(topo);
manifest.project = env.provenance();
let run_name = format!("{}-{}", render_name, crate::run_identity_digest(&manifest));
let run_name =
format!("{}-{}", render_name, crate::run_identity_digest(&manifest, &identity_hash));
// Bind each declared tap per the plan's subscription (mirrors
// run_signal_r — the shared bind_tap_plan/BoundTaps pair IS the mirror).
+31 -18
View File
@@ -540,19 +540,24 @@ fn compile_error_prose(e: &CompileError, names: &[String], params: &[Scalar]) ->
/// The single construction+run path shared by the `aura run <blueprint.json>` CLI
/// arm and its bit-identical test.
///
/// `topo`: `None` computes `topology_hash` inline from `signal` as always
/// (every existing caller); `Some(hash)` overrides it with the caller's own
/// **reference-semantics** hash (#343, revised) — the exec blueprint leg's
/// `--override` branch passes the loaded base document's content id, computed
/// BEFORE `reopen_all`, so the manifest reaching every consumer inside this
/// function (the record line AND the trace-store persistence below) carries
/// the base document's id, never the reopened topology's own, exactly as the
/// campaign leg's `run_blueprint_member` has always taken its `topo` as a
/// caller-supplied reference (`runner.rs`'s `&cell.strategy_id`).
/// `refs`: `None` computes both `topology_hash` (the content id) and the run
/// identity's `identity_hash` (the #171 `blueprint_identity_json` projection —
/// debug-symbol-blind, including to a C29 description) inline from `signal`,
/// as always (every existing caller). `Some((topo, identity))` overrides BOTH
/// with the caller's own **reference-semantics** hashes (#343, revised): the
/// exec blueprint leg's `--override` branch computes them from the loaded base
/// document BEFORE `reopen_all`, so every consumer inside this function (the
/// record line, the trace-store persistence below, AND the #311 handle) sees
/// the base document's hashes, never the reopened topology's own — computing
/// `identity_hash` from the REOPENED signal instead would show the overridden
/// param as open rather than bound, which would split a no-op `--override`
/// (one that reopens a param only to rebind it to its own default) from the
/// un-overridden run it is identical to. Mirrors the campaign leg's own
/// precedent for `topo` alone (`runner.rs`'s `&cell.strategy_id`).
#[allow(clippy::type_complexity)]
pub fn run_signal_r(
signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &Env,
plan: TapPlan, topo: Option<&str>,
plan: TapPlan, refs: Option<(&str, &str)>,
) -> Result<crate::RunOutcome<RunReport>, RunnerError> {
// #297 fieldtest finding B1 (milestone-safe-to-embed): mirror the CLI's
// own `exec_blueprint_leg` exposes-neither guard (`aura-cli/src/main.rs`,
@@ -574,13 +579,20 @@ pub fn run_signal_r(
// topology_hash's own two-line body, inlined: `content_id_of` over the
// canonical (#164) blueprint JSON — the CLI shell's `topology_hash`
// helper is the same primitive, kept single-sourced at `aura_research`.
// Skipped when the caller already supplies a reference-semantics hash
// (`topo: Some(..)`) — no need to hash a signal whose own topology_hash
// will be overridden anyway.
let topo: String = match topo {
Some(t) => t.to_string(),
None => aura_research::content_id_of(
&aura_engine::blueprint_to_json(&signal).expect("a buildable signal serializes"),
// `identity_hash` is the sibling #171 projection, over the SAME `signal`
// reference, feeding `run_identity_digest` in place of `topology_hash`
// (2026-07-27, C29). Both skipped when the caller already supplies
// reference-semantics hashes (`refs: Some(..)`) — no need to hash a
// signal whose own topology_hash/identity_hash will be overridden anyway.
let (topo, identity_hash): (String, String) = match refs {
Some((t, i)) => (t.to_string(), i.to_string()),
None => (
aura_research::content_id_of(
&aura_engine::blueprint_to_json(&signal).expect("a buildable signal serializes"),
),
aura_research::content_id_of(
&aura_engine::blueprint_identity_json(&signal).expect("a buildable signal serializes"),
),
), // before signal is consumed
};
// #311: the render name is only the readable PREFIX of the run's directory
@@ -632,7 +644,8 @@ pub fn run_signal_r(
manifest.broker = r_sma_broker_label(pip_size);
manifest.topology_hash = Some(topo);
manifest.project = env.provenance();
let run_name = format!("{}-{}", render_name, crate::run_identity_digest(&manifest));
let run_name =
format!("{}-{}", render_name, crate::run_identity_digest(&manifest, &identity_hash));
// Bind each declared tap per the plan's subscription, before bootstrap
// (#283): typed refusals for bad plans, record consumers hold their
// streaming writer in-graph, folds accumulate O(1), live closures run