Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,67 @@ Two distributions ship from this one repository and share a version:
The PyPI **distribution** name is `content-addressable` (hyphen); the **import**
name is `content_addressable` (underscore).

## [0.1.1] — the identity/classification layer

Additive: the frozen `0.1.0` core contract is **untouched**, every golden vector
is byte-identical, and identifiers minted under `0.1.0` remain valid. This
release makes *foreign* content identity representable without ever minting it
([#84], [ADR 0003]).

### Added — frozen for `0.1.x`

- **`RawContentId`** — CIDv1 · `raw` (`0x55`) · BLAKE3-256 (`0x1e`) · 32 bytes:
the identity of an opaque byte string, sibling to `ContentId` (the DAG-CBOR
profile). Same four presentation forms, same accessor names. Its bytes are
fixed by the CID specification, not by this crate, and pinned cross-language
by `tests/raw_vectors.json`. Rust **and** Python.
- **`ClassifiedCid { Content | Raw | Foreign(ForeignCid) }`** — classification of
any well-formed CID. The three variants are **pairwise disjoint and jointly
total**: `ForeignCid`'s every constructor rejects a recognized profile, so each
CID has exactly one representation, and `Deserialize` *derives* the variant
from the wire bytes rather than trusting it.
- **The profile law.** Codec + multihash algorithm + digest jointly constitute
identity. `RawContentId(x)` never equals `ContentId(x)` even when the digest
bytes coincide — no cross-type `PartialEq`, no `From` either way, and each
ingress rejects the other's codec. Compare identities as typed CID bytes,
never as `digest_hex()` (identical across profiles for the same digest).
- **`ContentId::from_dag_cbor_digest`** — the honest no-rehash door for a digest
the caller knows is over canonical DAG-CBOR.

### Deprecated

- **`ContentId::from_blake3_content_digest`** (Rust and Python) — it stamped the
DAG-CBOR codec on a digest it could not know came from DAG-CBOR. Behavior is
unchanged for `0.1.x`; removal is a major-version event. Successors:
`RawContentId::from_blake3_digest` for opaque bytes,
`ContentId::from_dag_cbor_digest` for known-DAG-CBOR digests.

### Added — experimental (NOT frozen), opt-in, default-off

- **`unstable-legacy`** — parse-only edge adapters for the dialects still in the
wild (`legacy::kyln` envelope-hex, `legacy::nessie` `<algo>:<hex>`,
`legacy::bare_blake3`), pinned by `tests/legacy_vectors.json`. Rust-only by
design. Canonical `FromStr` **never** learns a legacy dialect, so enabling
this cannot change how canonical text parses.
- **`unstable-migration`** — `IdentityMigration` / `MigrationKind`, a
content-addressed record stating that one identity superseded another.
Construction and both serde ingresses validate (non-mintable `to` and
self-migration are refused; `deny_unknown_fields` on a private wire shape).
**Its API and its bytes are both unfrozen until golden migration vectors
land** — its field names are load-bearing for its own id. Do not persist these
records as long-lived identity claims yet.

### Notes

- `ClassifiedCid` is named *Classified*, not *Verified*: it proves structural
validity and profile membership, never that content matches a digest. Content
verification remains `verify` / `ensure_content_id` (and `VerifiedStore`,
which does check bytes on read).
- Canonical emitted text is still base32-lower only.

[#84]: https://github.com/hartsock/content-addressable/issues/84
[ADR 0003]: docs/adr/0003-identity-profiles-and-classified-cids.md

## [0.1.0] — first stable-contract release

The first release to **freeze the core content-addressing contract** for the
Expand Down Expand Up @@ -101,4 +162,5 @@ release outside `0.1.x`.
- Non-integer floats are outside the canonical vector set (DAG-CBOR float rules
are handled per-language, not in the shared cross-language gate).

[0.1.1]: https://github.com/hartsock/content-addressable/releases/tag/v0.1.1
[0.1.0]: https://github.com/hartsock/content-addressable/releases/tag/v0.1.0
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ default-members = ["."]

[package]
name = "content-addressable"
version = "0.1.0"
version = "0.1.1"
# FROZEN policy (gate item #10, issue #9): the edition stays at 2021 and the
# MSRV floor is `1.85` for the `0.1.x` line. 1.85 is the Rust 2024 edition
# baseline, required transitively because blake3 >= 1.6 pulls cpufeatures 0.3
Expand Down Expand Up @@ -50,7 +50,7 @@ unstable-merkle = []
unstable-store = []
# `unstable-legacy` gates the explicit edge adapters for the legacy identifier
# dialects still in the wild (kyln envelope-hex, nessie `<algo>:<hex>`, bare
# BLAKE3 hex) -> `RawContentId` / `VerifiedCid` (`src/legacy.rs`, issue #84).
# BLAKE3 hex) -> `RawContentId` / `ClassifiedCid` (`src/legacy.rs`, issue #84).
# Default-OFF and NON-FROZEN on purpose: these exist to END the dialects, not
# to bless them; canonical output stays base32-lower and the primary
# `from_str` doors never learn these forms. Expected to shrink as consumers
Expand Down
2 changes: 1 addition & 1 deletion content-addressable-py/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
# targeting this manifest explicitly (see ../pyproject.toml `manifest-path`).
[package]
name = "content-addressable-py"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
rust-version = "1.85"
license = "Apache-2.0"
Expand Down
40 changes: 40 additions & 0 deletions docs/STABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,46 @@ 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.

### The `unstable-legacy` feature — experimental API, parse-only

The default-off `unstable-legacy` feature ships the edge adapters for the legacy
identifier dialects (`legacy::kyln`, `legacy::nessie`, `legacy::bare_blake3`).
**Its API surface is not frozen** — these adapters exist to *end* those dialects
and are expected to shrink and then go away, so treat them as a migration ramp
rather than a contract.

It defines **no new wire bytes of its own**: every adapter is parse-only and
returns an ordinary `RawContentId` / `ClassifiedCid`, whose bytes are already
frozen above. What *is* pinned is the mapping from each dialect to that result —
`tests/legacy_vectors.json` (Rust gate `tests/legacy_conformance.rs`; the Python
gate re-derives the SHA-256 rows with `hashlib`, so those rows are interop data
rather than restated shapes). The adapters are deliberately **Rust-only**: a
Python legacy-parsing face would widen exactly the surface [#84] narrows, so the
vectors carry the portability instead.

The frozen guarantee that *does* apply here is negative and stated above under
the profile law: canonical `FromStr` never learns a legacy dialect, so enabling
this feature cannot change how canonical text parses.

### The `unstable-migration` feature — experimental API **and wire bytes**

The default-off `unstable-migration` feature ships `IdentityMigration` /
`MigrationKind` ([#84]): a content-addressed record stating that one identity
superseded another. Construction and both serde ingresses are validating —
private fields, a single `new` that rejects a non-mintable `to` and a
self-migration, and a `Deserialize` that decodes a private wire shape with
`deny_unknown_fields` and re-runs that constructor.

**Neither its API nor its bytes are frozen.** The record is itself
`ContentAddressable`, so its field names and their order are load-bearing for
its own id: renaming a field moves the id of every migration record ever
written. That is exactly why it stays unfrozen **until golden migration vectors
land** and make its content-addressed representation explicit and reproducible
across languages, the same bar `tests/vectors.json` and `tests/raw_vectors.json`
already meet for the two mintable profiles. Until then it is deliberately absent
from the vector set, and downstream systems should not persist these records as
long-lived identity claims.

[#3]: https://github.com/hartsock/content-addressable/issues/3
[#4]: https://github.com/hartsock/content-addressable/issues/4
[#5]: https://github.com/hartsock/content-addressable/issues/5
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ build-backend = "maturin"

[project]
name = "content-addressable"
version = "0.1.0"
version = "0.1.1"
description = "IPLD-native content addressing for Python: data that carries its own proof of integrity (BLAKE3 + CIDv1 + canonical dag-cbor)."
readme = "README.md"
requires-python = ">=3.9"
Expand Down
108 changes: 108 additions & 0 deletions tests/stability_doc.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
//! Every `unstable-*` feature must carry a stability statement.
//!
//! # The gap this closes
//!
//! `docs/STABILITY.md` is the crate's answer to "may I persist this?" — the
//! whole reason it has a **What is NOT frozen** section. Two default-off
//! features (`unstable-legacy`, `unstable-migration`) shipped with their
//! non-frozen status recorded only in a `Cargo.toml` comment and a module doc,
//! so a consumer consulting the document that owns stability found nothing and
//! had to infer. For `unstable-migration` that inference is expensive in the
//! wrong direction: the record is itself content-addressed, so its field names
//! are load-bearing for its own id, and a consumer who assumed the bytes were
//! settled would persist identity claims that a later rename invalidates.
//!
//! A prose fix alone would rot on the next feature. This is the ratchet: adding
//! an `unstable-*` feature without documenting its stability fails here.
//!
//! Deliberately *not* asserting anything about what the prose says — a test
//! cannot judge whether a stability statement is correct, only that the surface
//! was not shipped unmentioned. The wording stays a review concern.

use std::path::Path;

/// The `unstable-*` feature names declared in `[features]`.
///
/// Hand-parsed rather than pulling a TOML dev-dependency: the shape is one
/// `name = [...]` per line, and this file is the only consumer.
fn declared_unstable_features(manifest: &str) -> Vec<String> {
let mut out = Vec::new();
let mut in_features = false;
for line in manifest.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_features = trimmed == "[features]";
continue;
}
if !in_features || trimmed.starts_with('#') {
continue;
}
if let Some((name, rest)) = trimmed.split_once('=') {
let name = name.trim();
if name.starts_with("unstable-") && rest.trim_start().starts_with('[') {
out.push(name.to_string());
}
}
}
out
}

#[test]
fn every_unstable_feature_has_a_stability_statement() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let manifest = std::fs::read_to_string(root.join("Cargo.toml")).expect("read Cargo.toml");
let stability =
std::fs::read_to_string(root.join("docs/STABILITY.md")).expect("read docs/STABILITY.md");

let features = declared_unstable_features(&manifest);
assert!(
!features.is_empty(),
"parsed no unstable-* features out of [features] — the parser drifted from the manifest, \
which would make this whole guard vacuous"
);

// Everything after the heading is the not-frozen half of the document.
let (_frozen, not_frozen) = stability
.split_once("## What is NOT frozen")
.expect("docs/STABILITY.md must keep its `## What is NOT frozen` section");

let missing: Vec<&String> = features
.iter()
.filter(|f| !not_frozen.contains(&format!("`{f}`")))
.collect();
assert!(
missing.is_empty(),
"these features ship default-off but are not mentioned under \
`## What is NOT frozen` in docs/STABILITY.md: {missing:?}.\n\
A default-off surface with no stability statement leaves consumers to \
guess whether its bytes are settled. Add a subsection saying what is \
unfrozen (API, wire bytes, or both) and the condition that would \
freeze it."
);
}

#[test]
fn the_parser_finds_the_features_and_ignores_prose() {
// A miniature manifest: comments, a non-features table, and a feature whose
// value is not a list (a dep-activating feature) must all be skipped.
let sample = "\
[package]\n\
unstable-decoy = [\"not in features\"]\n\
\n\
[features]\n\
# unstable-commented = []\n\
unstable-real = []\n\
default = []\n\
unstable-with-deps = [\"dep:serde\"]\n\
\n\
[dependencies]\n\
unstable-also-decoy = [\"nope\"]\n";
assert_eq!(
declared_unstable_features(sample),
vec![
"unstable-real".to_string(),
"unstable-with-deps".to_string()
],
"the parser must take unstable-* list-valued keys from [features] only"
);
}