Summary
serder's inception serializer unconditionally sets the identifier prefix i equal to the computed SAID d (the double-SAID / self-addressing case). It has no path to emit a basic-derivation inception, where i is the qb64 public key and i ≠ d. keripy produces such events (transferable identifiers with basic key derivation), and one is in our own differential corpus. Reading such an event and re-serializing it corrupts both d and i.
Reproduction
The first event in keri/tests/corpus/keystate.jsonl is a keripy-generated icp with i ≠ d:
d = ENPqUQcJMv0oydINXTnu4RgIxNXZxGO5bVVaIwad9p5j (Blake3-256 SAID)
i = DEPIjjhH8mxoUqbrIeKv0mWS1Nj-K8Z0ikpuehf6t7Kf (Ed25519 basic-derivation public key)
Read it with deserialize_event (works — the #142 read path handles i ≠ d correctly), then re-serialize:
let event = deserialize_event(raw).unwrap(); // OK: cesr READS basic inceptions faithfully
let reser = event.serialize().unwrap();
assert_eq!(reser.as_bytes(), raw); // FAILS
Result — both fields are rewritten:
| field |
keripy original |
after cesr read→write |
i |
DEPIjjhH… (public key) |
ECdHUvPz… (overwritten with a SAID) |
d |
ENPqUQcJ… (keripy single-SAID) |
ECdHUvPz… (recomputed as double-SAID) |
d also changes because the writer recomputes the SAID over a double-# placeholder (both d and i blanked), whereas keripy computed d over the event with i = the real key (single-SAID).
Root cause
EventRef::is_double_said (cesr/src/serder/serialize.rs) returns true for every Inception/DelegatedInception, so serialize_with backpatches the i slot with the computed SAID.
- The direct writer (
serialize/direct.rs render_icp) writes the SAID placeholder into i, never the event's actual prefix.
Provenance (not a #140/#141 regression)
This predates the #141 backend seam. At #139 (f274048), the pre-seam build_icp_json already hardcoded map.insert("i", said_value) with a doc comment framing it as intentional ("the double-SAID property … where the prefix is self-addressing"). #141 was a byte-identical refactor that faithfully preserved it. The i=d assumption dates to the original keri-serder implementation — a deliberate but incorrect "all inceptions are self-addressing" assumption.
Why no test caught it
keri/tests/differential.rs drives corpus events through deserialize_event and checks key state — it never re-serializes and byte-compares. The write-path lossiness on basic inceptions was therefore invisible. Surfaced by the #142 deterministic variant matrix, which tried to construct a single-SAID (i ≠ d) inception and found it isn't constructible through the writer.
Proposed fix (separate PR)
- Serialize the inception's actual prefix into
i; only set i = d when the prefix is self-addressing and equals the SAID (i.e. derive double-vs-single-SAID from the event's Identifier variant, not unconditionally).
- Compute the SAID single-SAID (only
d blanked) when i ≠ d.
- Add a corpus round-trip byte-identity test (read → re-serialize → assert equal) over
keystate.jsonl so this class can never regress silently again.
Scope
Write-path only; orthogonal to #142 (the read path is correct and handles these events faithfully). Basic-derivation identifiers are a keripy-parity requirement.
🤖 Generated with Claude Code
Summary
serder's inception serializer unconditionally sets the identifier prefixiequal to the computed SAIDd(the double-SAID / self-addressing case). It has no path to emit a basic-derivation inception, whereiis the qb64 public key andi ≠ d. keripy produces such events (transferable identifiers with basic key derivation), and one is in our own differential corpus. Reading such an event and re-serializing it corrupts bothdandi.Reproduction
The first event in
keri/tests/corpus/keystate.jsonlis a keripy-generatedicpwithi ≠ d:d = ENPqUQcJMv0oydINXTnu4RgIxNXZxGO5bVVaIwad9p5j(Blake3-256 SAID)i = DEPIjjhH8mxoUqbrIeKv0mWS1Nj-K8Z0ikpuehf6t7Kf(Ed25519 basic-derivation public key)Read it with
deserialize_event(works — the #142 read path handlesi ≠ dcorrectly), then re-serialize:Result — both fields are rewritten:
iDEPIjjhH…(public key)ECdHUvPz…(overwritten with a SAID)dENPqUQcJ…(keripy single-SAID)ECdHUvPz…(recomputed as double-SAID)dalso changes because the writer recomputes the SAID over a double-#placeholder (bothdandiblanked), whereas keripy computeddover the event withi= the real key (single-SAID).Root cause
EventRef::is_double_said(cesr/src/serder/serialize.rs) returnstruefor everyInception/DelegatedInception, soserialize_withbackpatches theislot with the computed SAID.serialize/direct.rs render_icp) writes the SAID placeholder intoi, never the event's actual prefix.Provenance (not a #140/#141 regression)
This predates the #141 backend seam. At #139 (
f274048), the pre-seambuild_icp_jsonalready hardcodedmap.insert("i", said_value)with a doc comment framing it as intentional ("the double-SAID property … where the prefix is self-addressing"). #141 was a byte-identical refactor that faithfully preserved it. Thei=dassumption dates to the original keri-serder implementation — a deliberate but incorrect "all inceptions are self-addressing" assumption.Why no test caught it
keri/tests/differential.rsdrives corpus events throughdeserialize_eventand checks key state — it never re-serializes and byte-compares. The write-path lossiness on basic inceptions was therefore invisible. Surfaced by the #142 deterministic variant matrix, which tried to construct a single-SAID (i ≠ d) inception and found it isn't constructible through the writer.Proposed fix (separate PR)
i; only seti = dwhen the prefix is self-addressing and equals the SAID (i.e. derive double-vs-single-SAID from the event'sIdentifiervariant, not unconditionally).dblanked) wheni ≠ d.keystate.jsonlso this class can never regress silently again.Scope
Write-path only; orthogonal to #142 (the read path is correct and handles these events faithfully). Basic-derivation identifiers are a keripy-parity requirement.
🤖 Generated with Claude Code