diff --git a/CHANGELOG.md b/CHANGELOG.md index b31a8c2..d0d85cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` `:`, + `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 @@ -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 diff --git a/Cargo.lock b/Cargo.lock index bdcf7f2..a4ff9a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,7 +96,7 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "content-addressable" -version = "0.1.0" +version = "0.1.1" dependencies = [ "blake3", "ipld-core", @@ -108,7 +108,7 @@ dependencies = [ [[package]] name = "content-addressable-py" -version = "0.1.0" +version = "0.1.1" dependencies = [ "content-addressable", "ipld-core", diff --git a/Cargo.toml b/Cargo.toml index 1a8d27d..f55791c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 @@ -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 `:`, 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 diff --git a/content-addressable-py/Cargo.toml b/content-addressable-py/Cargo.toml index 4c4134b..306331f 100644 --- a/content-addressable-py/Cargo.toml +++ b/content-addressable-py/Cargo.toml @@ -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" diff --git a/docs/STABILITY.md b/docs/STABILITY.md index 700eb14..c8eaf9c 100644 --- a/docs/STABILITY.md +++ b/docs/STABILITY.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 6bc51d8..e2cbe04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/stability_doc.rs b/tests/stability_doc.rs new file mode 100644 index 0000000..8c9ee3b --- /dev/null +++ b/tests/stability_doc.rs @@ -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 { + 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" + ); +}