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
+8 -4
View File
@@ -257,10 +257,14 @@ overwrote an earlier one's trace — is **resolved** (#311), and no longer
depends on naming at all: a run's trace directory is keyed by the run's own
identity (`traces/<name>-<id8>/`, a digest over its manifest), so two runs
that differ in any identity-bearing input — a different topology, an
`--override` value, the data window, the seed — land in two directories,
while two runs that differ in nothing land in one and the later simply
rewrites what the earlier wrote. The render name survives as the directory's
readable prefix, and `aura chart <name>` lists the runs recorded under it.
`--override` value, the data window, the seed — land in two directories, and
two runs that differ in nothing land in the same one. That directory's
*contents* are not wholesale-replaced on the second write, though: the
declared tap *plan* (which of a blueprint's taps this invocation subscribes)
is not itself identity-bearing, so a same-identity pair that persists a
different tap subset can strand an earlier tap file beside the later run's
own — tracked as #352. The render name survives as the directory's readable
prefix, and `aura chart <name>` lists the runs recorded under it.
Value forms are the typed-tag representations used everywhere in this
family of artifacts:
@@ -295,3 +295,10 @@ aggregate** over those members, not a persisted family-level record."
#319 sugar retirement, 2026-07-25):** "`aura campaign run <file|content-id>`
executes a campaign (a file is register-then-run sugar; the content id is
canonical): a zero-fault referential gate, then the process pipeline."
**Current-state "Single-run trace identity (#311)" clause (superseded
2026-07-27 — the audit that closed the #311 cycle found `topology_hash`
itself, a C24 content id, feeding the digest, in violation of C29's Id
treatment):** "What remains — the merged params, window, seed, broker,
instrument, `topology_hash`, and the project's `namespace`/`dylib_sha256` (the
C13 hot-reload discriminator) — is identity-bearing."
+12 -3
View File
@@ -263,9 +263,18 @@ partition between them records *how* a bound value was supplied (varied vs. held
at its default, #246) rather than *what* the run's effective parameterisation is —
the same provenance-not-identity distinction, applied to a different pair of keys;
the merge is lossless since the two vectors are disjoint by construction. What
remains — the merged params, window, seed, broker, instrument, `topology_hash`,
and the project's `namespace`/`dylib_sha256` (the C13 hot-reload discriminator) —
is identity-bearing. The two provenance keys are removed rather than blanked
remains — the merged params, window, seed, broker, instrument, and the
project's `namespace`/`dylib_sha256` (the C13 hot-reload discriminator) — is
identity-bearing, with one substitution (2026-07-27, C29): `topology_hash` is
a **content** id (`content_id_of(blueprint_to_json(..))`, #158/C24) that also
hashes a blueprint's `doc` field and every other C23 debug symbol, so a
description-only edit would otherwise mint a fresh directory for a
bit-identical run — forbidden by C29's own Id treatment ("description fields
… are blanked for the identity id"). The digest hashes
`blueprint_identity_json`'s debug-symbol-blind projection (#171) in
`topology_hash`'s place instead; `manifest.topology_hash` itself is
untouched, keeping its own #343 reference-semantics content id. The two
provenance keys are removed rather than blanked
because `ProjectProvenance.commit` skips serializing when it is `None`: blanking
would canonicalise an absent key to `null` and split one identity in two.
`aura-runner::run_identity_digest` is the single implementation; both single-run
@@ -146,6 +146,12 @@ run's own name, on both shapes: …"
"file I/O is `aura-registry::TraceStore`, persisting each run under
`runs/traces/<name>/` beside the run registry's `runs.jsonl`"
**Current-state "Single-run trace identity (#311)" clause (superseded
2026-07-27 — the audit that closed the #311 cycle found `topology_hash`
itself, a C24 content id, feeding the digest, in violation of C29's Id
treatment):** "Everything else the manifest carries is identity-bearing,
`project.dylib_sha256` included (it is what C13 hot-reload varies)."
**Current-state "Single run" + "Families" + "Newcomer" clauses (superseded by
the #319 sugar retirement, 2026-07-25):** "**Single run.** `aura run
<blueprint.json>` persists every tap the blueprint declares to the trace
+16 -1
View File
@@ -92,7 +92,22 @@ different pair: `params`/`defaults` record *how* a bound value was supplied
(varied vs. held at its default, #246), not *what* the run's effective
parameterisation is, and the two vectors are disjoint by construction, so the
merge is lossless. Everything else the manifest carries is identity-bearing,
`project.dylib_sha256` included (it is what C13 hot-reload varies). The
`project.dylib_sha256` included (it is what C13 hot-reload varies)
**except** `topology_hash` (2026-07-27, C29): it is a **content** id
(`content_id_of(blueprint_to_json(..))`, #158/C24) that also hashes a
blueprint's `doc` field and every other C23 debug symbol, so a
description-only edit would otherwise mint a fresh directory for a
bit-identical run, which C29's own Id treatment forbids. The digest hashes
`blueprint_identity_json`'s debug-symbol-blind projection (#171) in
`topology_hash`'s place instead; `manifest.topology_hash` itself is
untouched. That projection blanks every C23 debug symbol, not descriptions
alone — the render name, and node, role, output, tap and gang names — while
param openness stays identity-bearing; two blueprints differing only in
such names compute bit-identically, so one address is the correct answer.
One observable consequence: two structurally identical blueprints whose
declared *tap names* differ share a directory while writing differently
named tap files, and the later run's `index.json` lists only its own, since
the write path never prunes (tracked as #352, unchanged here). The
manifest is assembled *before* the tap bind and the same value is reused for
the record, so the handle can never name a directory whose identity the
stored record does not describe. Consequences: two runs of one blueprint
@@ -60,6 +60,11 @@ content id and are blanked for the identity id, exactly as `Composite.doc`
([C24](c24-blueprint-data.md)), so node docs move no id. Documents have no
identity projection — their pair is: absent `description` is byte-identical
to the field-less form (existing content ids stable), present participates.
A single `aura exec` run's #311 trace-directory handle honours this same
treatment (2026-07-27 audit fix): its digest hashes the blueprint's identity
projection, not `manifest.topology_hash` (a content id), so a
description-only blueprint edit mints no fresh directory for a bit-identical
run (`aura-runner::run_identity_digest`, [C18](c18-registry.md)).
**Forbids.** The engine evaluating description text — the gate is
deterministic string shape, never content judgement (no freetext logic