Skip to content

Latest commit

 

History

History
164 lines (128 loc) · 8.48 KB

File metadata and controls

164 lines (128 loc) · 8.48 KB

Stability contracts

What is frozen for the 0.1.x line, and what is not. A frozen contract is a stability guarantee: changing any of these is a breaking release outside 0.1.x. The package is 0.1.0 — the first release to freeze these contracts, so downstream systems can persist and link against them today.

Issue links (#N) provide provenance; the contract itself is stated so you do not need to open every issue to depend on it.

What is frozen (0.1.x)

CID profile — the byte contract (#3, #4)

Every id is a CIDv1 with a fixed shape, built as BLAKE3(bytes)Multihash::wrap(0x1e, digest)Cid::new_v1(0x71, mh):

Field Value Frozen
CID version v1 only
Codec DAG-CBOR, 0x71
Hash BLAKE3, multihash 0x1e
Digest length 32 bytes

The hash and codec are fixed, not selectable for 0.1.x. The serde representation of ContentId is frozen: it serializes as a DAG-CBOR tag-42 link via the inner Cid's serde. The cross-language byte gate tests/vectors.json — asserted in both the Rust (tests/conformance.rs) and Python conformance tests — pins the canonical DAG-CBOR bytes, the base32 CID text, the CID binary envelope (to_bytes()), and digest_hex, so an id's public byte forms cannot drift across languages.

Canonical encoding

Canonical DAG-CBOR is the canonical form: strict map-key ordering, definite-length arrays/maps, and tag-42 links are enforced by the codec, so determinism is a property of the encoder, not of caller discipline. Equal values produce equal bytes produce equal ids.

Construction: checked vs unchecked (#5)

from_canonical_bytes is the fast, unchecked minting primitive: it hashes the bytes it is handed and carries a normative precondition that they are canonical DAG-CBOR. Passing non-canonical bytes mints a misleading id — a logic error, unenforced by design. The safe defaults are content_id / to_canonical_dagcbor (which produce canonical bytes for you).

from_canonical_bytes_checked re-encode-validates foreign / untrusted bytes and returns the typed ContentError::NonCanonical (or DecodingError for non-DAG-CBOR).

The pairing is frozen as from_canonical_bytes / from_canonical_bytes_checked — the unchecked door is not renamed to _unchecked.

Presentation contract (#6)

A ContentId names four distinct presentation forms, each frozen:

Form Rust Python What it is
Canonical text Display / to_string() str(id) multibase base32-lower (b…), the IPLD-canonical CID string
Binary envelope to_bytes() / from_bytes() to_bytes() / from_bytes() the full CID binary form
Bare digest digest_bytes() -> [u8; 32] digest_bytes() -> bytes the raw 32-byte BLAKE3 hash
Bare-digest-hex digest_hex() -> String digest_hex() -> str 64-char lower-hex of the digest, no prefix

Display is the inverse of FromStr for base32-lower; that round-trip is frozen and tested. FromStr's tolerance of other multibases is a documented convenience, not a contract. Every conformance vector's digest_hex() is pinned in tests/vectors.json and asserted in both languages, so the accessor's bytes cannot drift across Rust and Python.

Why there is no full-CID hex method. Three mutually-incompatible "hex" conventions exist for a CID in the wild:

  1. bare-digest-hex — hex of the raw 32-byte digest; this is digest_hex().
  2. full-CID-bytes-hex — hex of to_bytes() (the whole envelope). Deliberately not a method: a caller who needs it writes hex::encode(id.to_bytes()) and owns that choice. Blessing a cid_hex() would add a third "hex" that invites the exact confusion this contract exists to end. It can be added later additively without breaking anything.
  3. multibase base32-lower — the Display string; the canonical text form.

Error policy (#7)

ContentError is #[non_exhaustive], so variants may be added additively without a breaking release outside 0.1.x. The codec source types are hidden behind Box<dyn Error + Send + Sync + 'static> (no serde_ipld_dagcbor generics leak into the public signature); InvalidCid preserves the underlying cid::Error as a #[source]. There are no #[from] impls (a deliberate freeze decision). ContentError: Send + Sync + 'static is locked by a compile-time test, and the error module documents the operation → variant map.

Verification: verify vs ensure_content_id (#8)

verify returns Ok(false) on a mismatch (never Err) — frozen. Its strict sibling ensure_content_id returns Err(ContentError::VerificationFailed) on a mismatch and Ok(()) on a match, making VerificationFailed a real, tested error path. Both return contracts are part of the frozen surface.

Crate-root exports (#9)

The public crate-root re-export surface is frozen and minimal: ContentId, ContentAddressable, ContentError, and the canonical module (reached as canonical::to_canonical_dagcbor etc., not re-exported at the root). The codec/hash codes DAG_CBOR_CODEC / BLAKE3_HASH_CODE are pub in content_id but deliberately not promoted to the crate root; BLAKE3_DIGEST_LEN is private. MerkleNode is re-exported only under the experimental unstable-merkle feature; the store surface only under the experimental unstable-store feature. Removing or narrowing a frozen export after 0.1.0 is a breaking release outside 0.1.x; adding one is allowed.

MSRV & edition (#9)

MSRV 1.85 (the Rust 2024 edition baseline, required transitively because blake3 >= 1.6 pulls cpufeatures 0.3, an edition-2024 crate) and edition 2021 are frozen for 0.1.x, marked as policy in Cargo.toml. A dedicated CI job pins dtolnay/[email protected] so a transitive dependency cannot silently raise the real floor; Cargo.lock is committed for reproducible resolution. Bumping the MSRV or edition is an intentional, SemVer-relevant change.

No-rehash digest bridge (#10)

ContentId::from_blake3_content_digest([u8; 32]) is part of the byte contract: it emits the same frozen CID shape (0x71 / 0x1e / 32-byte digest) as from_canonical_bytes, sharing one private wrapping site so the two doors are byte-identical for the same digest. It is additive (no existing bytes change) and unchecked — the caller asserts the digest is BLAKE3 over canonical DAG-CBOR. The wrapping rule downstream BLAKE3-native systems persist and link against is fixed for 0.1.x.

What is NOT frozen

The unstable-merkle feature — experimental node bytes

The default-off unstable-merkle feature ships MerkleNode<T> (a payload: T plus parents: BTreeSet<ContentId>, whose id derives from both). Its serialized node bytes are not frozen. The layout depends on (a) the ContentId tag-42 serde repr and (b) the payload / parents field-key strings; it is pinned only once Merkle conformance vectors land (post-0.1.0). Merkle vectors are deliberately kept out of tests/vectors.json (the frozen cross-language byte-parity gate). Until then, changing the node bytes is not a breaking change. After 0.1.0 freezes them, it is a breaking release outside 0.1.x.

The unstable-store feature — experimental API

The default-off unstable-store feature ships the CID-addressed node store seam (NodeStore, NodeStoreExt, VerifiedStore, MemoryStore, AddressedBytes, StoreError, StoreOperation). Its trait/API surface is not frozen and may change until the catalog stabilizes. The seam defines no new wire bytes of its own — it stores bytes whose layout is owned elsewhere — so it adds nothing to tests/vectors.json.

Its proof obligations (the seam theorems, the backend refinement laws, and the deferred forced-collision TLA+ model tracked in #71) are documented in the store module docs; see the README's store section for the trust boundary in brief.