ADR-0042 — The concrete attachment-reference shape (Attachment / Rendition / SealRef)¶
- Status: Accepted
- Date: 2026-07-08
- Refines: ADR-0013 (attachment reference shape)
- Reconciles with: ADR-0041 (progress-note media manifest)
Context¶
ADR-0013 / §3.14
settled the day-one requirements for the attachment-reference shape — five reserves: a
self-describing content digest, a seal indicator / DEK-wrap reference, clear-text descriptor
metadata, a rendition set, and the inline-vs-reference distinction — but never froze the
concrete encoding. In the meantime ADR-0041 (the
progress-note narrative format) already referenced this shape for its payload.media
manifest, describing it in prose as carrying "self-describing content digest, media type, byte
length, rendition set, seal indicator / DEK-wrap reference, inline-vs-reference, and the
clear-text descriptor metadata … plus a note-local id" — but the code had not caught up: the
walking-skeleton AttachmentRef in cairn-event was still a flat single-reference stub
satisfying only two of the five reserves (digest + descriptor metadata), with no nesting, no
seal field, and no inline distinction.
The first real consumer — the §5.4 John-Doe photo-evidence attachment — forces finalizing the
shape now: the reference rides the signed, immutable event, so once the first attachment-bearing
event is signed, its field order is frozen under that signature forever. No production event
carries an attachment yet (every event today sets attachments: []), which is the last free
moment to get this right without a migration.
Decision¶
Attachments are a three-type shape, implemented in crates/cairn-event/src/attachment.rs
and re-exported as EventBody.attachments: Vec<Attachment>. The field order below is the
canonical CBOR encoding (structural move 1 — one writer, one serialization) and must never be
reordered once the first attachment-bearing event is signed:
pub struct Attachment {
pub descriptor: String,
pub renditions: Vec<Rendition>,
}
pub struct Rendition {
pub role: String,
pub alg: String,
pub digest_hex: String,
pub media_type: String,
pub byte_len: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inline: Option<serde_bytes::ByteBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seal: Option<SealRef>,
}
pub struct SealRef {
pub alg: String,
pub dek_wrap: String,
}
-
Attachmentis one logical binary clinical artifact: a clear-textdescriptor(feeds the event twin and the §5.9 safety projection even when bytes are sealed or absent) plus arenditionsset — N content-addressed views of the same logical artifact (original, preview, extracted text, …), each with its own sync priority. A photo ships oneoriginalrendition today; the set is reserved for previews/extracts added later without a wire change. -
Renditioncarries the per-view identity and the two reserved options.alg+digest_hexare the self-describing multihash content address:digest_hexnames the plaintext bytes whensealisNone, and the ciphertext bytes whensealisSome— there is no convergent encryption for sealed blobs (deriving the key from the plaintext would leak "someone holds this exact file," a confirmation attack; confidentiality outranks dedup).media_typeandbyte_lenare the clear-text descriptor metadata that lets a sealed, pending, or unparseable blob still render down the legibility ladder.inline(Some(bytes)= tiny blob embedded on the eager plane;None= by-reference on the lazy sync §6.6 tier) andseal(Some= sealed under a per-blob DEK, crypto-shreddable via the ADR-0005 ladder;None= plaintext) are bothOptionwithskip_serializing_if = "Option::is_none", so the common unsealed by-reference rendition omits both keys from the wire entirely rather than encoding an explicit null. -
What is structurally can't-retrofit versus additive-but-reserved-now.
renditions: Vec<Rendition>is the one genuinely can't-retrofit field: a flat single reference today could never become a set later without changing the bytes — and therefore the content-address — of every attachment already signed. It must be nested from day one, and is.sealandinlineare, in the strict technical sense, additive later (a newOptionfield defaults to absent under ADR-0012's additive-only rule) — they are reserved now rather than deferred because (a) §3.14 already names them as day-one requirements, and (b) shipping them now lets the shape be validated against a real consumer (the photo) in one pass rather than bolted on to a shape already carrying production data. -
Byte-identical migration from the walking-skeleton stub. Every event minted before this ADR carries
attachments: []; an emptyVecserializes to an empty CBOR array regardless of element type, so replacingAttachmentRefwithAttachmentchanges the content-address of zero existing events. -
EventBody.attachmentsversus the ADR-0041 note manifest — one primitive, two carriers.EventBody.attachments: Vec<Attachment>carries attachments on non-narrative events (the photo-evidence event; marks/belongings/EMS-context later) — no inline anchoring is needed, so no note-local id is needed either. The ADR-0041payload.mediamanifest is the sameAttachment/Rendition/SealRefprimitive, defined once here and never duplicated, plus a note-localid(so a markdown anchorcan address a manifest entry) and ADR-0041's own tightening ofdescriptorto mandatory-non-empty at the note floor. ADR-0041 described this shape before the code existed; this ADR supplies and freezes the implementation ADR-0041 was already pointing at — no reconciliation gap remains between the two ADRs. -
The floor learns a lazy blob reference per rendition, not per attachment. The submit door and the mirrored remote-apply door already walk
attachments[*]and learn a lazy blob reference by readingdigest_hex/media_type/byte_lenoff each element (db/005,db/020) — that walk moves one level deeper, toattachments[*].renditions[*], skipping any rendition carryinginline(its bytes ride the event, not the lazy tier; no blob reference to learn). This is safety-critical in-DB SQL and is built ascairn_learn_attachment_refs(b)indb/027, called from both doors in place of their current inline loop — one shared implementation, no drift between the two enforcement points, the same single-source discipline the twin hook already used (ADR-0039).
Consequences¶
Easier / now guaranteed:
- The attachment-reference shape is validated by a real consumer (photo evidence) before any
production data exists, rather than shipped speculatively and discovered wrong later.
- The rendition set, seal reserve, and inline reserve are all expressible from the first
attachment-bearing event — no future envelope change is needed to add previews, sealing, or
small inline blobs.
- ADR-0041's forward reference to this shape is now backed by code; the two ADRs describe the
identical primitive with no drift.
- The by-reference, unsealed common case stays minimal on the wire (inline/seal both absent).
Harder / accepted costs, honest limits:
- Plaintext only. seal ships reserved-and-None; per-blob DEK sealing and byte-tier key
custody (ADR-0005 applied to blobs) is deferred to a later slice. No attachment can yet be
crypto-shredded.
- Single rendition, no preview. The first consumer ships one original rendition; preview /
thumbnail generation needs an image-processing dependency (AGPL-compatible, still to be
selected) and is deferred. The rendition set is structurally ready; only one member exists.
- Bytes stay local. The reference replicates eagerly, as designed, but cross-node byte fetch
(the sync §6.6 lazy swarm/resumable tier) is
existing POC machinery not yet wired into cairn-node; a receiving peer correctly renders
"referenced here — not yet retrieved," but remote retrieval of this shape's bytes is
unexercised.
- The frozen POC harness diverges. poc/walking-skeleton/harness/{spike_0002,agent_standin}.py
author advisory.added events with the flat pre-ADR-0042 AttachmentRef shape. poc/ is
frozen historical spikes, outside the workspace build and CI, so nothing breaks — but after
this ADR their flat attachments no longer register a blob reference through
cairn_learn_attachment_refs. Recorded as a known, accepted divergence, not fixed.
How we would know the bet is failing: a real attachment workflow needs a Rendition or
Attachment field this ADR did not reserve (forcing a second, harder envelope change against
live data); or the rendition-per-element floor walk (point 6) proves too costly on Pi-class
hardware for attachments with large rendition sets (mitigation: the walk is O(renditions), which
stays small — previews and extracts, not raw gigabytes, ride as separate renditions only in
metadata).
Not a new founding principle. This is ADR-0013's day-one envelope reserve given a concrete, frozen encoding — principle 1 (content-addressing) and principle 11 (additive-only, self-describing artifacts) applied to the exact byte layout, plus the §9 one-writer-one-serialization discipline ADR-0040 already established for signatures.