spec: 0104 topology-identity hash (boss-signed)

Boss-signed via grounding-check PASS (fresh-context agent): all five
load-bearing assumptions about current behaviour are ratified by named
currently-green tests — the byte-canonical golden
(signal_serializes_to_canonical_golden), the --content-id CLI contract
(graph_introspect_content_id_is_deterministic_and_distinguishes and the
bad-document refusal), the three topology_hash roles (introspect
content id, blueprint-store round-trip key, reproduce fetch anchor —
each with its own green test), the I64:4-vs-5 distinguishing property,
and the bound-array openness encoding (bound slot textually present,
open slot absent).

Design carried from the #171 reconciliation (adversarially reviewed):
the identity hash is an additive SIBLING of topology_hash —
introspection-only this cycle, no manifest field, no store key; the
byte-canonical form and every consumer of it stay byte-for-byte
untouched. Reviewer note carried to planning: allowing --content-id
and --identity-id together relaxes the current exactly-one flag
dispatch in graph_construct.rs — the usage tests must be adjusted
deliberately, not incidentally.

refs #180, refs #171
This commit is contained in:
2026-07-02 19:39:27 +02:00
parent d5c4361a97
commit b3a2e9cf8d
+184
View File
@@ -0,0 +1,184 @@
# The topology-identity hash — Design Spec
**Date:** 2026-07-02
**Status:** Draft — awaiting sign-off (boss run: grounding-check gate)
**Authors:** orchestrator + Claude
**Cycle:** 0104 — milestone "Project environment — the project-as-crate
authoring loop" (#180); work item #171 (fork resolutions: the cycle-0104
reconciliation comment on #171)
## Goal
Two blueprints expressing the same topology get the same **identity hash**
regardless of authoring path — an op-script-built graph and a Rust-builder
graph, or two builders differing only in debug names, become comparable by
id. The identity hash is **additive**: the byte-canonical `topology_hash`
keeps all three of its current roles (introspect content id, reproduction
store key, reproduce fetch anchor) byte-for-byte untouched.
## Architecture
One new engine-side projection and one additive CLI flag:
- **`blueprint_identity_json` (aura-engine, `blueprint_serde.rs`)** builds
the same serialization document as `blueprint_to_json`, then normalizes
every non-load-bearing debug symbol before serializing: the composite
debug-name, primitive instance names, bound-param names, role names, and
output re-export names are all replaced by `""`. Everything load-bearing
survives untouched: node **type ids**, node **order**, **edges** (raw
indices), role **targets and order**, output **(node, field) pairs and
order**, and the **`bound` arrays' positions, kinds, and values** — so
param **openness** stays identity-bearing (a bound slot carries
`pos`+`value`, an open slot is textually absent) and an open blueprint
never shares an id with any bound instance.
- **`aura graph introspect --identity-id` (aura-cli)** — a sibling of the
existing `--content-id`: hashes the identity JSON through the same
research-side SHA-256 helper `content_id` already uses (sha2 stays out of
the engine, invariant 8). `--content-id` output is byte-unchanged.
Decisions carried from the #171 reconciliation comment (adversarially
reviewed): the initial "redefine `topology_hash`" shape was **refuted**
`topology_hash` keys the byte-exact reproduction store and `reproduce`
recovers bootstrap points **by param-path name** against the fetched
blueprint, so instance names are load-bearing on that path and a name-blind
store key would let cross-path name-twins clobber it. Hence: sibling id,
zero golden churn, reproduction provably unaffected. No manifest field and
no store keyed by the identity hash this cycle (no dedup consumer exists —
the deferred-until-a-consumer discipline).
## Concrete code shapes
### The user-facing comparison (the acceptance-criterion evidence)
```console
# two authoring paths, one topology: the op-script (composite named "graph")
# and the Rust builder (named "sma_signal") — whatever input form
# `--content-id` consumes today, `--identity-id` consumes identically
$ aura graph introspect --content-id <op-script path's input>
9f3c…e1 # byte id, path A
$ aura graph introspect --identity-id <op-script path's input>
5b77…a2 # topology id, path A
$ aura graph introspect --identity-id <rust-builder path's input>
5b77…a2 # SAME topology id
$ aura graph introspect --content-id <rust-builder path's input>
41d0…07 # byte ids still differ
```
(The hex values are illustrative; the input form mirrors `--content-id`'s
existing contract exactly.)
### Engine: the identity projection (secondary, before → after shape)
`blueprint_to_json` today walks the composite into a `BlueprintDoc` DTO and
serializes it. The identity projection reuses that walk and normalizes the
DTO in place:
```rust
/// Identity-canonical form: the byte-canonical document with every
/// non-load-bearing debug symbol blanked (invariant 11 / C23). Feeds the
/// research-side identity hash; NOT a load path and NOT the reproduction
/// store's byte form.
pub fn blueprint_identity_json(bp: &Composite) -> String {
let mut doc = build_doc(bp); // the existing blueprint_to_json walk
strip_debug_symbols(&mut doc.blueprint);
serialize_doc(&doc) // the existing serializer
}
fn strip_debug_symbols(b: &mut BlueprintData) {
b.name = String::new();
for node in &mut b.nodes {
match node {
NodeData::Primitive(p) => {
p.name = None;
for bound in &mut p.bound {
bound.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
}
}
```
(Field/type names above follow the existing `blueprint_serde.rs` DTOs; the
planner pins exact identifiers. If `build_doc`/`serialize_doc` are not yet
factored out of `blueprint_to_json`, factoring them is part of this cycle —
behaviour-preserving for the byte path, guarded by the existing canonical
golden.)
### CLI: the sibling flag (secondary)
`GraphIntrospectCmd` gains one flag beside `--content-id`:
```rust
/// Print the topology-identity id (debug names stripped) and exit.
#[arg(long)]
identity_id: bool,
```
Its branch mirrors the `--content-id` branch byte-for-byte, substituting
`blueprint_identity_json` for the canonical bytes before the same
`content_id` SHA-256 helper.
## Components
| Component | Crate | New/changed |
|---|---|---|
| `blueprint_identity_json` + `strip_debug_symbols` (+ doc-walk factoring if needed) | aura-engine | new fn(s) |
| `--identity-id` flag + branch | aura-cli (`graph_construct.rs`) | additive |
| Property tests (engine) + e2e (CLI) | both | new tests |
Untouched by design: `topology_hash` stamping, `put_blueprint`/
`get_blueprint`, `aura reproduce`, `blueprint_to_json` bytes, every golden.
## Data flow
```
Composite ──build_doc──► BlueprintDoc ──serialize──► byte-canonical JSON ──► content_id / topology_hash / store
└─clone──► strip_debug_symbols ──serialize──► identity JSON ──► identity id (introspect only)
```
## Error handling
No new failure modes: `--identity-id` shares `--content-id`'s input path
(stdin/file op-list or blueprint doc) and its existing refusal contract;
combining `--content-id --identity-id` prints both, one per line, in that
order (flags are not mutually exclusive).
## Testing strategy
1. **Engine property tests (`blueprint_serde.rs`):**
- renamed twins: two builders identical up to `.named(...)` and composite
name → equal identity JSON, unequal canonical JSON;
- openness: a blueprint with `scale` bound vs unbound → unequal identity
JSON (boundness survives);
- bound values: I64:4 vs I64:5 → unequal identity JSON (the existing
distinguishing property, now on the identity form);
- wiring: swapping two edges' targets → unequal identity JSON (order and
indices load-bearing);
- the existing byte-canonical golden stays byte-identical (the factoring
is behaviour-preserving).
2. **CLI e2e (`graph_construct.rs` tests):** an op-script-built doc and the
Rust-built `sma_signal` fixture expressing the same topology → same
`--identity-id` output, different `--content-id` output; `--content-id`
alone byte-unchanged against its existing pin.
3. **Regression:** full workspace suite green unchanged (reproduce,
goldens, store untouched).
## Acceptance criteria
1. The op-script and Rust-builder twins share an identity id while their
content ids differ (the #171 headline, proven e2e).
2. An open blueprint and any bound instance of it never share an identity
id; bound-value twins differing in one value never share one.
3. `--content-id`, `topology_hash`, the blueprint store, and `aura
reproduce` are byte-for-byte unaffected (existing tests + goldens green
unchanged).
4. The identity projection is documented as introspection-only this cycle
(no manifest field, no store key) — the deferral recorded on #171.