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
+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() {