diff --git a/CHANGELOG.md b/CHANGELOG.md index c1d8321..4e1551b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,109 @@ drafts the missing entries from the merge's actual diff bot PR for review — the file never falls silently behind main, and no drafted text lands without a human merge. +## [Unreleased] + +### Added +- **Provenance attestation (`SPEC.md` §6.5, F6–F9; + [ADR 0010](./docs/adr/0010-provenance-attestation.md)).** A digest is + tamper-evident only to someone who already trusts whoever recorded it; the + digest and the frame it describes come from the same unauthenticated party, so + a provider willing to fabricate a frame will fabricate its digest too. A + detached Ed25519 signature closes that gap. `contextgraph_types::attest` adds a + source-first **hash chain** over a frame's provenance links (so no link can be + inserted, dropped, reordered, or edited undetectably), a **frame commitment** + binding that chain head to the frame's full `(provider_id, frame_id, + content_digest)` identity — without which a signature could be lifted off one + frame and stapled onto a fabricated one — and an **RFC 6962 Merkle root** with + inclusion proofs, so one frame can be proven a member of a signed answer + without disclosing its siblings. Verification is offline and pure, and returns + a *named* verdict rather than a boolean: "this frame was altered after signing" + and "I was handed a truncated key" call for opposite responses. +- **`contextgraph-types` gains an off-by-default `attestation` feature** + (`sha2`, `ed25519-dalek`). The default dependency set is unchanged — serde + only — so the crate's zero-dependency promise still holds for the pure wire + consumer, and `ProvenanceAttestation` itself always compiles so a host can + relay an attestation it cannot check. +- **`contextgraph-types/tests/attestation_vectors.rs`** publishes the byte + vectors a reimplementation in any language reconciles against. §6.5.1 defines a + normative encoding, and a normative encoding with no published vectors is a + rule two implementations can both believe they follow while computing different + hashes. A diff in that file is a wire-breaking change. +- **`contextgraph_host::fold_to_edges` is now public** — the Lost-in-the-Middle + *placement* separated from the *ranking*, for hosts that rank frames with their + own reranker or per-provider quotas instead of raw `score`. +- **TypeScript SDK:** `KnownFrameKind`, `KNOWN_FRAME_KINDS`, `isKnownFrameKind`. + **Python SDK:** `KnownFrameKind`, `KNOWN_FRAME_KINDS`. + +### Changed +- **Crate version `1.0.0` → `2.0.0`; protocol version stays `contextgraph/1.0`.** + The first time the two axes in [docs/stability.md](./docs/stability.md) actually + disagree, and the reason they are documented as independent. The open-`FrameKind` + change below is breaking in Rust and invisible on the wire, so the crates take a + major and the protocol does not — a `1.x` peer and a `2.x` peer still interoperate + in both directions. All nine workspace crates inherit the bump from + `workspace.package`; the internal `contextgraph-*` floors move from `>=1.0.0` to + `>=2.0.0`, because `contextgraph-host` 2.0.0 does not compile against + `contextgraph-types` 1.x and the old floor would have let cargo resolve a pair + that cannot build. The Python and TypeScript SDK manifests move in lockstep for + the same break in their own type systems (`FrameKind` widens, so an exhaustive + `switch` relying on `never`-narrowing stops type-checking), as does + `create-contextgraph-provider` — whose TypeScript default pin had also fallen a + major behind its own manifest at `^0.1.0`. Source-level upgrade steps: + [MIGRATION.md §5](./MIGRATION.md). `docs/stability.md` previously said a breaking + redesign required "both `contextgraph/2.0` and crate version `2.0.0`", which would + have forbidden this bump two lines after declaring the axes independent; it now + states the implication in the one direction that holds. +- **`FrameKind` is now an open vocabulary** + ([ADR 0011](./docs/adr/0011-open-frame-kind-vocabulary.md)). **Rust-semver + breaking, wire-compatible.** The closed enum contradicted the protocol's own + §3.1 no-flag-day promise and §13 U2: a frame carrying a kind added in a later + `1.x` did not degrade on a `1.0` host, it *failed to deserialize*, and every + exhaustive `match` downstream broke the day a variant was added. `FrameKind` + now carries `Unknown(String)` — preserving the original string, so a relaying + host re-emits it byte-identically, which `#[serde(other)]` cannot do — and is + `#[non_exhaustive]`, so every future kind addition is non-breaking. It is no + longer `Copy`. `contextgraph_host::frame_kind_name` becomes + `fn(&FrameKind) -> &str` delegating to `FrameKind::as_str`. On the wire nothing + changed except that a frame which previously failed to parse now parses. +- **`score` semantics are stated normatively (`SPEC.md` §6.6, F10).** F1 bounds + `score` to `[0, 1]`, which is a *range*, not a *scale* — nothing defined what + `0.8` meant, and the host ranked across providers as if it were commensurable. + `score` is now specified as provider-local and ordinal: a host **MUST NOT** + apply a cross-provider threshold or present a raw score as a cross-provider + relevance measure, and a host that orders across providers by raw score **MUST** + document it as its own policy. Mandating calibration was considered and + rejected as unenforceable — §7 could make budget honesty checkable because + token cost is a function of bytes both sides observe, and relevance has no such + anchor. `order_by_value` now documents itself as exactly such a policy choice. +- **`SPEC.md` §13 U2** additionally requires that a receiver re-emitting a frame + with an unrecognised `kind` preserve the original string verbatim. +- **`GOVERNANCE.md` states the consent boundary**: the host enforces *local* + consent, not organizational policy. Fleet RBAC, central policy distribution, + and aggregated cross-machine audit are out of scope and belong to a product + built on the protocol's primitives. Written down before it is contested, + because the pressure runs one way and every individual request to relax it is + reasonable. +- **README positioning.** "The canonical architecture" replaced with a + verifiable claim — the only context protocol whose conformance is + machine-checked. The title said "draft v0.1.0" while the protocol had frozen at + `contextgraph/1.0` and the crates shipped `1.0.0`. + +### Fixed +- **The version-compatibility example illustrated nothing.** `contextgraph/1.0` + and `contextgraph/1.0` were given as two versions that interoperate — in + **seven** files (`README.md`, `SPEC.md`, `docs/overview.md`, `docs/index.md`, + `docs/registry.md`, `docs/protocol-surface.md`, `docs/protocol-advantages.md`), + the residue of a global rename. Now `contextgraph/1.0` and `contextgraph/1.1`. +- **Stale pre-freeze language** in `docs/registry.md` and `docs/index.md`, which + described a "freeze from `contextgraph/1.0` to `contextgraph/1.0`" and a "path + to `contextgraph/1.0`" that has already happened, and linked a + `GOVERNANCE.md#the-path-to-contextgraph10` anchor that no longer exists. +- **`schema/contextgraph-envelope.schema.json`** now carries a `$comment` at + `FrameKind` stating that the closed `enum` is an authoring lint and **not** the + interop contract — the one place a reader is most likely to mistake the schema + for the wire rule. + ## [0.1.2] — 2026-08-01 (crate release) All four crates published from `main`: `contextgraph-types`, diff --git a/Cargo.lock b/Cargo.lock index e263509..c465aea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.1" @@ -151,7 +157,7 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -209,9 +215,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "contextgraph-conformance" -version = "1.0.0" +version = "2.0.0" dependencies = [ "async-trait", "clap", @@ -227,7 +239,7 @@ dependencies = [ [[package]] name = "contextgraph-host" -version = "1.0.0" +version = "2.0.0" dependencies = [ "async-trait", "contextgraph-types", @@ -244,7 +256,7 @@ dependencies = [ [[package]] name = "contextgraph-mcp-bridge" -version = "1.0.0" +version = "2.0.0" dependencies = [ "clap", "contextgraph-host", @@ -257,7 +269,7 @@ dependencies = [ [[package]] name = "contextgraph-mcp-server" -version = "1.0.0" +version = "2.0.0" dependencies = [ "async-trait", "contextgraph-host", @@ -269,7 +281,7 @@ dependencies = [ [[package]] name = "contextgraph-refprov" -version = "1.0.0" +version = "2.0.0" dependencies = [ "contextgraph-host", "contextgraph-types", @@ -279,7 +291,7 @@ dependencies = [ [[package]] name = "contextgraph-ripgrep" -version = "1.0.0" +version = "2.0.0" dependencies = [ "contextgraph-refprov", "contextgraph-types", @@ -287,7 +299,7 @@ dependencies = [ [[package]] name = "contextgraph-trace" -version = "1.0.0" +version = "2.0.0" dependencies = [ "contextgraph-types", "serde", @@ -297,7 +309,7 @@ dependencies = [ [[package]] name = "contextgraph-treesitter" -version = "1.0.0" +version = "2.0.0" dependencies = [ "contextgraph-refprov", "contextgraph-types", @@ -305,10 +317,12 @@ dependencies = [ [[package]] name = "contextgraph-types" -version = "1.0.0" +version = "2.0.0" dependencies = [ + "ed25519-dalek", "serde", "serde_json", + "sha2", ] [[package]] @@ -339,6 +353,33 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -357,6 +398,16 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -378,6 +429,30 @@ dependencies = [ "syn", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -394,6 +469,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -536,7 +617,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -906,6 +987,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1003,7 +1094,16 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1018,7 +1118,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -1111,6 +1211,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustls" version = "0.23.42" @@ -1164,6 +1273,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1257,6 +1372,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "slab" version = "0.4.12" @@ -1279,6 +1403,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 2b3595e..a0c7532 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ members = [ ] [workspace.package] -version = "1.0.0" +version = "2.0.0" edition = "2024" rust-version = "1.90" license = "MIT OR Apache-2.0" @@ -33,6 +33,7 @@ async-trait = "0.1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } futures-util = "0.3" sha2 = "0.10" +ed25519-dalek = "2" clap = { version = "4", features = ["derive", "env"] } colored = "3" libc = "0.2" diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 2fde6fd..35aac90 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -81,3 +81,33 @@ It does not specify tool invocation — that is [MCP](https://modelcontextprotocol.io)'s scope — and will not absorb it. An agent that needs both composes them: Context Graph Protocol frames feed the prompt, MCP tools do the work. See ["How Context Graph Protocol relates to MCP"](./docs/overview.md). + +### The consent boundary + +**The host enforces local consent, not organizational policy.** + +`contextgraph-host` answers one question: may *this* provider, on *this* +machine, send *this* content to *this* class of destination, given a receipt +*this* user or policy granted? Everything it needs to answer that lives on the +machine. Fleet-level RBAC, org-wide policy distribution, aggregated audit +records across many machines, and central revocation are **out of scope** — they +belong to whatever product operates a fleet, and Oxagen is one such product +([ADR 0007](./docs/adr/0007-protocol-product-boundary.md)). + +This line is written down before it is contested, because the pressure runs one +way. Every request to make the host "just" read a policy file, "just" check a +central registry, or "just" ship its receipts somewhere is individually +reasonable and collectively fatal: a host that needs an organization behind it +to function is no longer a protocol implementation an individual can run, and +"conformant" would quietly come to mean "connected to someone's control plane." + +The protocol supplies the **primitives** an organizational layer is built from — +[`ConsentReceipt`](./SPEC.md) as a portable, auditable artifact, `EgressScope` as +a shared destination vocabulary, and provenance attestations (§6.5) as offline +evidence. A fleet product consumes those primitives. It does not get to move +into the host to reach them. + +A proposal that would put organizational policy inside the host is not rejected +for being a bad idea — it may be an excellent product. It is rejected as +**out of scope**, and the answer does not change because the proposer is the +maintainer. diff --git a/MIGRATION.md b/MIGRATION.md index 43c6181..2d33327 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -152,3 +152,74 @@ which lives in a private repository — unresolvable for anyone outside it. All normative text now lives in [`SPEC.md`](./SPEC.md) in this repository, with stable anchors (`H1`, `B3`, `F5`, …) that will not be renumbered within the `contextgraph/1` family. Cite those. + +## 5. Crate `1.x` → `2.0.0` — a Rust break, not a wire break + +**Nothing changes on the wire.** `PROTOCOL_VERSION` is still `contextgraph/1.0`, +the bytes are unchanged, and a `1.x` peer and a `2.x` peer interoperate in both +directions. The major is spent entirely on the Rust API, because +[ADR 0011](./docs/adr/0011-open-frame-kind-vocabulary.md) opens the `FrameKind` +vocabulary and there is no way to do that compatibly in an `enum`. See +[docs/stability.md](./docs/stability.md) for why the two axes are allowed to +disagree. + +Take the bump with: + +```toml +contextgraph-types = "2" +contextgraph-host = "2" +``` + +Then fix three call-site shapes. Each is a compile error, so the compiler +enumerates the work for you — none of this fails silently at runtime. + +### 5.1 `match` on `FrameKind` needs a wildcard arm + +`FrameKind` gained an `Unknown(String)` variant and is now `#[non_exhaustive]`, +so an exhaustive match no longer compiles: + +```rust +match frame.kind { + FrameKind::Snippet => …, + // …the other six… + FrameKind::Unknown(ref kind) => render_opaque(kind), // if you can use it + _ => render_opaque(frame.kind.as_str()), // otherwise +} +``` + +The wildcard is not boilerplate you are being made to write: per SPEC.md §13 U2 +a receiver **MUST NOT** reject a frame for carrying a kind it does not know. The +arm is where that obligation now lives, and the type system is what stops you +from forgetting it when `contextgraph/1.1` names an eighth kind. + +### 5.2 `FrameKind` is no longer `Copy` + +An unknown kind owns its wire string, and a `String` cannot be `Copy`. Where you +relied on the implicit copy, borrow — or `.clone()` when you need an owned value: + +```rust +let kind = frame.kind.clone(); // was: let kind = frame.kind; +if matches!(&frame.kind, FrameKind::Doc) { … } +``` + +Prefer `frame.kind.as_str()` where you only wanted the name; it borrows and +allocates nothing. + +### 5.3 `frame_kind_name` takes a reference + +```rust +contextgraph_host::frame_kind_name(&kind) // was: frame_kind_name(kind) +``` + +It used to return `&'static str`, which duplicated the vocabulary in a second +place and could not name a kind the host did not know. The returned lifetime is +now tied to the kind, because an unknown kind owns its string. + +### 5.4 SDKs move in lockstep + +`contextgraph-sdk` (Python) and `@contextgraph/sdk` (TypeScript) also go to +`2.0.0`, for the same reason in their own type systems: `FrameKind` widens to +accept any string, so an exhaustive `switch` that relied on `never`-narrowing +stops type-checking. Narrow with the exported `isKnownFrameKind` / +`KNOWN_FRAME_KINDS` when you need to branch only on kinds you understand. The +Go SDK is unchanged in this release — porting it is tracked in issue #93. diff --git a/README.md b/README.md index c6c4895..77638e9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Context Graph Protocol (draft v0.1.0) +# Context Graph Protocol (`contextgraph/1.0`) [![CI](https://github.com/macanderson/context-graph-protocol/actions/workflows/ci.yml/badge.svg)](https://github.com/macanderson/context-graph-protocol/actions/workflows/ci.yml) [![contextgraph-types on crates.io](https://img.shields.io/crates/v/contextgraph-types.svg)](https://crates.io/crates/contextgraph-types) [![contextgraph-types docs](https://img.shields.io/docsrs/contextgraph-types)](https://docs.rs/contextgraph-types) @@ -7,7 +7,9 @@ https://contextgraphprotocol.org -**The canonical architecture for building context graphs that agents use to reason over.** +**The only context protocol whose conformance is machine-checked** — every +guarantee below is enforced by an executable suite with an adversarial mode, not +asserted in prose. > New to this codebase? Read the [Engineer's Guide](./docs/GUIDE.md) — one doc > covering the coding principles, the full schema, and every ADR, in plain English. @@ -170,7 +172,7 @@ prompt. MCP tools do the work. check on purpose, so you know the checks are real. - **Stability you can pin.** The protocol version is `contextgraph/1.0`. Two versions interoperate when they share a major family, the part before the first dot. So - `contextgraph/1.0` and `contextgraph/1.0` both belong to family `contextgraph/1` and interoperate. + `contextgraph/1.0` and `contextgraph/1.1` both belong to family `contextgraph/1` and interoperate. When the draft freezes, every deployed provider keeps working. No flag day. --- diff --git a/SPEC.md b/SPEC.md index 16a8d0f..83d6c0f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -95,7 +95,7 @@ minor = 1*DIGIT The **major family** is the substring up to (not including) the first `.`. Two versions interoperate **if and only if** they share a major family. -`contextgraph/1.0` and `contextgraph/1.0` both belong to `contextgraph/1` +`contextgraph/1.0` and `contextgraph/1.1` both belong to `contextgraph/1` and interoperate; `contextgraph/2.0` does not. This is what lets the freeze drop `-draft` without a flag day. An @@ -277,6 +277,11 @@ loud. | **F3** | `citation_label` **MUST** be non-empty — a host must be able to cite a frame by a human label, never a bare id. | `frame-validity` | | **F4** | `valid_from`, `valid_to`, `recorded_at`, and `as_of` **MUST** match `YYYY-MM-DDTHH:MM:SS(.f+)?Z`. | `frame-validity` | | **F5** | Provenance of kind `file` **MUST** carry a digest matching `sha256:<64 lowercase hex>`. | `frame-validity` | +| **F6** | A `ProvenanceAttestation` **MUST** be detached — it **MUST NOT** appear inside the frame it signs, nor inside any hash preimage this spec defines. | `contextgraph_types::attest` | +| **F7** | An attestation's `signed_commitment` **MUST** be the `sha256:<64 lowercase hex>` rendering of a commitment computed exactly as §6.5.2 or §6.5.3 specifies. | `contextgraph_types::attest` | +| **F8** | A verifier that does not recognise an attestation's `algorithm` **MUST** report it as uncheckable and **MUST NOT** treat the frame as attested. "I cannot check this" is never "this is good". | `contextgraph_types::attest` | +| **F9** | A host **MUST NOT** reject or drop a frame solely because it carries an attestation the host cannot verify; an unverifiable attestation degrades the frame to *unattested*, exactly as if it carried none. | `contextgraph_types::attest` | +| **F10** | `score` is **provider-local and ordinal** — this spec defines no shared scale. A host **MUST NOT** apply a cross-provider `score` threshold, and **MUST NOT** present a raw `score` as a cross-provider measure of relevance. A host that *orders* frames from different providers by raw `score` **MUST** document it as its own policy choice, never as a protocol guarantee. | host composition | ### 6.1 Temporal profile (F4) @@ -388,6 +393,187 @@ deferring the resolve *operation* keeps 1.0 honest: it ships no capability a hos cannot use, and the operation arrives later as a clean additive minor rather than a breaking change. +### 6.5 Provenance attestation (F6–F9) + +F5 makes a frame's provenance **tamper-evident**. It does not make it +**evidence**. A digest proves the bytes have not changed since someone wrote +that number down; it says nothing about who wrote it. The digest and the frame +it describes come from the same unauthenticated party, so a provider willing to +fabricate a frame is equally willing to fabricate its digest, and every check in +§6.2 passes. Detecting *accidental* drift and proving *deliberate* honesty are +different problems, and only a signature solves the second. + +A **provenance attestation** is a detached signature over a commitment to a +frame's identity and its provenance chain. It is **optional**: a conformant +provider may serve no attestations at all, and a conformant host may verify +none. What is not optional is the construction — an attestation that exists must +be computed exactly this way, or two implementations will disagree about whether +the same evidence is genuine. + +#### 6.5.1 Canonical encoding + +Each provenance link encodes as its typed fields in **declaration order**, each +length-prefixed: + +```text +enc_str(s) = uint32be(byte_length(utf8(s))) ‖ utf8(s) +enc_opt(None) = 0x00 +enc_opt(Some(s)) = 0x01 ‖ enc_str(s) + +encode(link) = enc_str(link.type) + ‖ enc_opt(link.uri) ‖ enc_opt(link.range) + ‖ enc_opt(link.digest) ‖ enc_opt(link.method) + ‖ enc_opt(link.by) +``` + +Field order is **normative**. So is the length prefix: bare concatenation is +ambiguous, and without prefixes a link with `uri: "ab", range: "c"` encodes +identically to one with `uri: "a", range: "bc"` — a collision an adversary picks +rather than searches for. The presence byte is equally load-bearing: without it +`uri: null` and `uri: ""` collide, and a link's URI could be deleted from a +signed chain without disturbing the hash. + +This encoding is deliberately **not** RFC 8785 (JCS), which the Context Exchange +Provider profile uses for `record_hash`. JCS is right for a record, whose hash +covers an open-ended JSON document. A provenance link is six optional strings, +and for that shape JCS only adds a dependency on a conforming JSON canonicalizer +— whose number formatting and Unicode escaping rules are precisely where +cross-language implementations silently diverge. Any language can produce the +encoding above from the typed fields with no library at all. + +#### 6.5.2 Chain head and frame commitment + +The links of `provenance` fold **source-first** — the order §6 already requires +them to be carried in — into a hash chain: + +```text +h₋₁ = SHA256("contextgraph/attest/1/genesis") +hᵢ = SHA256("contextgraph/attest/1/link" ‖ hᵢ₋₁ ‖ encode(linkᵢ)) +chain_head = hₙ₋₁ , or h₋₁ when provenance is empty +``` + +Because each step consumes the previous head, no link can be inserted, removed, +reordered, or edited without changing the result — the property a set of +independent per-link digests never had. An empty chain hashes to the genesis +value rather than to zero, so "this frame claims no provenance" is a signed +assertion rather than a gap. + +The signed preimage for a single frame binds that head to the frame's full +identity: + +```text +frame_commitment = SHA256( + "contextgraph/attest/1/frame" + ‖ enc_str(provider_id) ‖ enc_str(frame.id) + ‖ enc_opt(frame.content_digest) + ‖ chain_head ) +``` + +**The identity binding is not optional.** Two frames citing the same source share +a chain head, so a signature over the head alone can be lifted from one frame and +stapled to another: it verifies, and the evidence is invented. Including the +*(provider id, frame id, `content_digest`)* triple of §6.3 means a signature binds +to one frame from one provider carrying one set of bytes, or it binds to nothing. +`content_digest` is included as an *option* because a frame is permitted to carry +none (D3); the encoding records that absence honestly rather than substituting a +placeholder. + +#### 6.5.3 Result-set Merkle root + +A provider signing a whole answer commits to a Merkle root over its frames' +commitments, taken in the canonical order of §6.3, using RFC 6962 hashing: + +```text +leaf(c) = SHA256(0x00 ‖ c) +node(l, r) = SHA256(0x01 ‖ l ‖ r) +MTH({}) = SHA256("contextgraph/attest/1/merkle-empty") +MTH({c}) = leaf(c) +MTH(C) = node( MTH(C[0..k]), MTH(C[k..n]) ), k = largest power of 2 < n +``` + +The distinct leaf and interior prefixes are what stop an interior node's hash +from being presented as a leaf — without them a subtree could masquerade as a +single frame. The RFC 6962 split is chosen over the common "duplicate the last +leaf on an odd level" shortcut because that shortcut admits two distinct leaf +sets with the same root; acceptable for a checksum, disqualifying for evidence. + +An **inclusion proof** carries the leaf index, the leaf count, and the sibling +hash at each level with the side it sits on. The leaf count is part of the proof +because a root alone does not pin the tree's size, and a verifier that ignores it +can be shown a proof from a differently-shaped tree. This is what makes a signed +answer selectively disclosable: a host proves one frame was in the set without +revealing the others. + +#### 6.5.4 Verification + +Verification is **offline and pure**: a commitment, an attestation, and a public +key are sufficient. A verifier recomputes the commitment from the frame in hand, +compares it to `signed_commitment` **before** examining the signature, and only +then checks the signature over the commitment bytes. + +The comparison order is deliberate. A mismatch means the frame changed after +signing; a signature failure means the key is wrong or the signature forged. +Reporting the first as the second sends an operator hunting a key-management bug +when the actual finding is tampering. + +Verifiers **MUST** distinguish these outcomes rather than collapsing them into a +boolean — F8's "uncheckable" and "invalid" are different findings with opposite +responses — and, per F9, an unverifiable attestation degrades a frame to +*unattested* rather than disqualifying it. A host that dropped such frames would +hand any peer a denial-of-service primitive: attach a malformed attestation and +watch the evidence disappear. + +Implementations **SHOULD** use a strict Ed25519 verifier — one rejecting +small-order public keys and non-canonical signature encodings. A signature two +conforming verifiers can disagree about is not evidence. + +### 6.6 What `score` means, and does not (F10) + +F1 constrains `score` to `[0, 1]`. That is a **range**, not a **scale**, and the +difference matters the moment a host composes frames from more than one +provider. Nothing in this specification defines what `0.8` means, and nothing +could: one provider's score is a cosine similarity, another's a BM25 rank +normalized by its own corpus, a third's a hand-tuned blend. Two providers +returning `0.8` are not making the same claim, and the same provider need not +mean the same thing across two queries. + +So `score` is **provider-local and ordinal**: it orders *that provider's* frames +against *that query*. It is not a measurement, and it is not comparable across +sources. + +**Why this is stated rather than fixed.** The obvious alternative is to mandate +calibration — require providers to map scores onto a shared scale. It is +unenforceable, and unenforceable requirements are worse than absent ones. There +is no reference corpus a conformance suite could score against without +prescribing what relevance *is*, which would put this specification in the +business of defining retrieval quality. A provider could satisfy any calibration +rule we wrote while its numbers stayed meaningless, and the suite would certify +it. §7 could make budget honesty checkable because token cost is a function of +bytes both sides observe; relevance has no such anchor. Claiming comparability +we cannot verify is exactly the self-attestation §11.1 exists to rule out. + +**What a host does instead.** Any cross-provider ordering is the *host's* +policy, and it owns the consequences: per-provider quotas, round-robin +interleaving, a reranker it controls, an explicit trust weighting it can defend +— or, most simply, ranking by raw `score` and saying so. All are permitted. What +F10 forbids is passing that choice off as something the protocol guaranteed. + +A host **MAY** apply a threshold to a **single** provider's scores, where the +ordering is meaningful. Applying one uniformly across providers silently prefers +whichever provider scores most generously — a ranking decided by an +implementation detail of someone else's retriever, which is precisely the +unaccountable behavior this protocol exists to eliminate. + +**The reference host, stated plainly.** `dedup_cross_provider` compares scores +across providers, but only to pick a survivor among frames already proven to be +*the same evidence* by content digest or overlapping file provenance — it breaks +a tie between duplicates and never decides what is relevant. `order_by_value` +*does* rank across providers by raw `score` to place frames at the +attention-favored edges of the prompt. That is a deliberate, documented default +for hosts that have no better ranking policy, not a claim that the scores are +commensurable; a host with a reranker should order frames itself and use +`fold_to_edges` for placement alone. + --- ## 7. Budget honesty @@ -723,7 +909,7 @@ bias of §15 real rather than aspirational. | # | Requirement | | - | ----------- | | **U1** | A receiver **MUST** ignore an object member it does not recognise, in any envelope, capability set, frame, or nested object — it **MUST NOT** reject the message on that basis. This is what lets a `1.x` minor add an optional field that a `1.0` peer harmlessly drops. | -| **U2** | The `FrameKind` set (`snippet`, `symbol`, `fact`, `doc`, `memory`, `episode`, `graph`) is **closed within a major family**; a new kind is a `1.x` addition. A host that receives an unrecognised `kind` **MUST** treat the frame as opaque evidence — it **MAY** ignore it, but **MUST NOT** crash. New *open* vocabularies (`rel`, error `code`, `egress_scope`) grow without a version bump; a receiver **MUST NOT** reject an unknown value in any of them (§8.1, §10 X1, §4.1). | +| **U2** | The `FrameKind` set (`snippet`, `symbol`, `fact`, `doc`, `memory`, `episode`, `graph`) is the **base vocabulary of a major family**; a new kind is a `1.x` addition. A host that receives an unrecognised `kind` **MUST** treat the frame as opaque evidence — it **MAY** decline to specialise its handling, but **MUST NOT** fail to deserialise, reject, or crash — and if it re-emits the frame it **MUST** preserve the original `kind` string verbatim. New *open* vocabularies (`rel`, error `code`, `egress_scope`) grow without a version bump; a receiver **MUST NOT** reject an unknown value in any of them (§8.1, §10 X1, §4.1). | | **U3** | Names containing a `:` are **reserved for namespacing**: a vendor-specific `rel`, `egress_scope`, or error `code` **MUST** be namespaced (`vendor:name`, non-empty on both sides) so it can never collide with a base value this spec defines or later reserves. Unprefixed names in these vocabularies belong to the protocol. | | **U4** | A field this spec defines is never repurposed within `contextgraph/1`: its name, type, and meaning are stable. A field that is superseded is **deprecated** — kept parseable and documented as deprecated for the life of the major family — never deleted or redefined. Deletion or redefinition requires a new major family (§3.1). | diff --git a/contextgraph-conformance/Cargo.toml b/contextgraph-conformance/Cargo.toml index 0c6ae08..14483d8 100644 --- a/contextgraph-conformance/Cargo.toml +++ b/contextgraph-conformance/Cargo.toml @@ -24,8 +24,8 @@ dist = false [dependencies] # Floor requirements — see the note in contextgraph-host/Cargo.toml: caret reqs break # the release version stamp at the first minor bump. -contextgraph-types = { path = "../contextgraph-types", version = ">=1.0.0" } -contextgraph-host = { path = "../contextgraph-host", version = ">=1.0.0" } +contextgraph-types = { path = "../contextgraph-types", version = ">=2.0.0" } +contextgraph-host = { path = "../contextgraph-host", version = ">=2.0.0" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/contextgraph-conformance/src/lib.rs b/contextgraph-conformance/src/lib.rs index 5ec8094..160127d 100644 --- a/contextgraph-conformance/src/lib.rs +++ b/contextgraph-conformance/src/lib.rs @@ -845,13 +845,13 @@ async fn check_kinds_filter(host: &Host, id: &str, caps: &Capabilities) -> Check return CheckResult::skip( CHECK_KINDS_FILTER, format!( - "provider declares kind `{declared}`, which is outside the closed FrameKind vocabulary, so §Q1 cannot be probed" + "provider declares kind `{declared}`, which is outside the base FrameKind vocabulary, so §Q1 cannot be probed" ), ); }; let query = ContextQuery { - kinds: vec![kind], + kinds: vec![kind.clone()], ..sample_query() }; match host.query_provider(id, &query).await { @@ -860,7 +860,7 @@ async fn check_kinds_filter(host: &Host, id: &str, caps: &Capabilities) -> Check .frames .iter() .filter(|frame| frame.kind != kind) - .map(|frame| format!("{} (kind={})", frame.id, frame_kind_name(frame.kind))) + .map(|frame| format!("{} (kind={})", frame.id, frame_kind_name(&frame.kind))) .collect(); if off_kind.is_empty() { CheckResult::pass( @@ -888,20 +888,19 @@ async fn check_kinds_filter(host: &Host, id: &str, caps: &Capabilities) -> Check } } -/// Parse a declared capability kind string back into the closed [`FrameKind`] +/// Parse a declared capability kind string back into the **base** [`FrameKind`] /// vocabulary. `None` for anything outside it — a provider may declare an /// extension kind, and §Q1 simply has nothing to say about it. +/// +/// [`FrameKind::from_wire`] never fails: since the kind vocabulary was opened +/// for forward compatibility, an unrecognized string parses to +/// [`FrameKind::Unknown`] rather than erroring. This check wants the narrower +/// question — "is this one of the seven kinds §Q1 is written about?" — so it +/// parses and then filters on [`FrameKind::is_known`]. Restating the seven +/// names here would put the vocabulary in a second place and let the two drift. fn frame_kind_from_wire(kind: &str) -> Option { - match kind { - "snippet" => Some(FrameKind::Snippet), - "symbol" => Some(FrameKind::Symbol), - "fact" => Some(FrameKind::Fact), - "doc" => Some(FrameKind::Doc), - "memory" => Some(FrameKind::Memory), - "episode" => Some(FrameKind::Episode), - "graph" => Some(FrameKind::Graph), - _ => None, - } + let parsed = FrameKind::from_wire(kind); + parsed.is_known().then_some(parsed) } /// **§G3/§G4** — a graph-declaring provider must actually do something with diff --git a/contextgraph-host/Cargo.toml b/contextgraph-host/Cargo.toml index d890384..33e8fc9 100644 --- a/contextgraph-host/Cargo.toml +++ b/contextgraph-host/Cargo.toml @@ -21,7 +21,11 @@ publish = true # stops matching at the first minor bump (path deps must satisfy `version` # too). ">=" keeps the stamped build resolving at any version and remains a # valid crates.io requirement for the independent Context Graph Protocol publish (PUBLISHING.md). -contextgraph-types = { path = "../contextgraph-types", version = ">=1.0.0" } +# +# The floor moved to 2.0.0 with the open-`FrameKind` break (ADR 0011): this +# crate does not compile against `contextgraph-types` 1.x, so a floor of +# ">=1.0.0" would let cargo resolve a pair that cannot build. +contextgraph-types = { path = "../contextgraph-types", version = ">=2.0.0" } serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/contextgraph-host/src/compose.rs b/contextgraph-host/src/compose.rs index 566ae89..8ea5b9a 100644 --- a/contextgraph-host/src/compose.rs +++ b/contextgraph-host/src/compose.rs @@ -76,7 +76,7 @@ fn render_frame(provider_id: &str, frame: &ContextFrame) -> String { "\n{content}\n\n", provider = escape_attribute(provider_id), id = escape_attribute(&frame.id), - kind = frame_kind_name(frame.kind), + kind = frame_kind_name(&frame.kind), cite = escape_attribute(cite), // A `reference` frame carries no inline content — it must be resolved // (`context/resolve`, a later phase) before composition; here it renders @@ -354,6 +354,24 @@ fn merge_provenance(base: &[Provenance], extra: &[Provenance]) -> Vec) -> Vec<(String, ContextFrame)> { // Rank best-first: score desc, then canonical FrameId asc as the tiebreak. frames.sort_by(|(pa, fa), (pb, fb)| { @@ -366,7 +384,13 @@ pub fn order_by_value(mut frames: Vec<(String, ContextFrame)>) -> Vec<(String, C /// Deal an already-ranked (best-first) sequence to alternating ends: best at the /// top, second at the bottom, third just inside the top, and so on. -fn fold_to_edges(ranked: Vec) -> Vec { +/// +/// This is the Lost-in-the-Middle *placement* separated from the *ranking*. +/// [`order_by_value`] pairs the two, ranking by raw `score`; a host with its own +/// reranker, per-provider quotas, or a trust weighting should rank the frames +/// itself and call this directly, because ranking across providers by raw +/// `score` is a policy choice and not a protocol guarantee (`SPEC.md` §6.6, F10). +pub fn fold_to_edges(ranked: Vec) -> Vec { let n = ranked.len(); let mut slots: Vec> = Vec::with_capacity(n); slots.resize_with(n, || None); diff --git a/contextgraph-host/src/ingest.rs b/contextgraph-host/src/ingest.rs index 7658007..2a9a5a9 100644 --- a/contextgraph-host/src/ingest.rs +++ b/contextgraph-host/src/ingest.rs @@ -1689,7 +1689,7 @@ impl Artifact { let cost = budget_tokens(&content); let mut frame = ContextFrame::full( self.id.clone(), - self.kind, + self.kind.clone(), self.title.clone(), content, self.score, @@ -1709,7 +1709,7 @@ impl Artifact { let cost = budget_tokens(&inline); let mut frame = ContextFrame::full( self.id.clone(), - self.kind, + self.kind.clone(), self.title.clone(), inline.clone(), self.score, @@ -1731,7 +1731,7 @@ impl Artifact { fn as_reference(&self, provider_id: &str) -> ContextFrame { let mut frame = ContextFrame::reference( self.id.clone(), - self.kind, + self.kind.clone(), self.title.clone(), self.content_ref(provider_id), self.address_hash.clone(), @@ -1787,7 +1787,7 @@ impl IngestProvider { let id = id.into(); let mut kinds: Vec = artifacts .iter() - .map(|a| frame_kind_name(a.kind).to_string()) + .map(|a| frame_kind_name(&a.kind).to_string()) .collect(); kinds.sort(); kinds.dedup(); diff --git a/contextgraph-host/src/lib.rs b/contextgraph-host/src/lib.rs index ce57bf7..5e3ff41 100644 --- a/contextgraph-host/src/lib.rs +++ b/contextgraph-host/src/lib.rs @@ -75,7 +75,7 @@ pub mod wire; pub use compose::{ AuditEntry, Citation, ComposedPrompt, CompositionAudit, DedupDrop, Deduped, ExclusionReason, FrameDisposition, VerificationState, budget_split, compose_context, compose_for_prompt, - dedup_cross_provider, order_by_value, + dedup_cross_provider, fold_to_edges, order_by_value, }; pub use consent::{ConsentDecision, ConsentRecord, ConsentStore}; pub use error::HostError; diff --git a/contextgraph-host/src/provider.rs b/contextgraph-host/src/provider.rs index 4ae7e89..50bc638 100644 --- a/contextgraph-host/src/provider.rs +++ b/contextgraph-host/src/provider.rs @@ -66,16 +66,14 @@ pub trait ContextProvider: Send + Sync { /// The snake_case wire name of a [`FrameKind`], matching its `serde` /// representation and the strings a provider lists in /// [`Capabilities::query`]'s `kinds`. -pub fn frame_kind_name(kind: FrameKind) -> &'static str { - match kind { - FrameKind::Snippet => "snippet", - FrameKind::Symbol => "symbol", - FrameKind::Fact => "fact", - FrameKind::Doc => "doc", - FrameKind::Memory => "memory", - FrameKind::Episode => "episode", - FrameKind::Graph => "graph", - } +/// +/// A thin delegation to [`FrameKind::as_str`], kept because several call sites +/// read better as a function. It used to be a hand-written `match` returning +/// `&'static str`; that duplicated the vocabulary in a second place and, worse, +/// could not name a kind the host did not know. Now that the vocabulary is open +/// the borrow is tied to the kind, because an unknown kind owns its string. +pub fn frame_kind_name(kind: &FrameKind) -> &str { + kind.as_str() } /// Whether a provider is worth querying for a given request. A query with no @@ -88,7 +86,7 @@ pub fn capability_matches(caps: &Capabilities, query: &ContextQuery) -> bool { return true; } query.kinds.iter().any(|requested| { - let name = frame_kind_name(*requested); + let name = frame_kind_name(requested); caps.query.kinds.iter().any(|served| served == name) }) } @@ -133,8 +131,8 @@ mod tests { (FrameKind::Episode, "episode"), (FrameKind::Graph, "graph"), ] { - assert_eq!(frame_kind_name(kind), name); - let serde_name = serde_json::to_value(kind).unwrap(); + assert_eq!(frame_kind_name(&kind), name); + let serde_name = serde_json::to_value(&kind).unwrap(); assert_eq!(serde_name, serde_json::Value::String(name.to_string())); } } diff --git a/contextgraph-mcp-bridge/Cargo.toml b/contextgraph-mcp-bridge/Cargo.toml index 3aa19de..fcfb57e 100644 --- a/contextgraph-mcp-bridge/Cargo.toml +++ b/contextgraph-mcp-bridge/Cargo.toml @@ -11,10 +11,10 @@ homepage.workspace = true publish = false [dependencies] -contextgraph-types = { path = "../contextgraph-types", version = ">=1.0.0" } +contextgraph-types = { path = "../contextgraph-types", version = ">=2.0.0" } # Reused only for the wire `Envelope` enum + its NDJSON codec, so the bridge's # CGP side stays byte-for-byte the reference wire rather than a re-spelling. -contextgraph-host = { path = "../contextgraph-host", version = ">=1.0.0" } +contextgraph-host = { path = "../contextgraph-host", version = ">=2.0.0" } serde.workspace = true serde_json.workspace = true sha2.workspace = true diff --git a/contextgraph-mcp-server/Cargo.toml b/contextgraph-mcp-server/Cargo.toml index 8fabc40..557a8eb 100644 --- a/contextgraph-mcp-server/Cargo.toml +++ b/contextgraph-mcp-server/Cargo.toml @@ -11,8 +11,8 @@ homepage.workspace = true publish = false [dependencies] -contextgraph-types = { path = "../contextgraph-types", version = ">=1.0.0" } -contextgraph-host = { path = "../contextgraph-host", version = ">=1.0.0" } +contextgraph-types = { path = "../contextgraph-types", version = ">=2.0.0" } +contextgraph-host = { path = "../contextgraph-host", version = ">=2.0.0" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/contextgraph-trace/Cargo.toml b/contextgraph-trace/Cargo.toml index 503c55f..16bea04 100644 --- a/contextgraph-trace/Cargo.toml +++ b/contextgraph-trace/Cargo.toml @@ -26,7 +26,7 @@ publish = true [dependencies] # Floor requirement — see the note in contextgraph-host/Cargo.toml: caret reqs # break the release version stamp at the first minor bump. -contextgraph-types = { path = "../contextgraph-types", version = ">=1.0.0" } +contextgraph-types = { path = "../contextgraph-types", version = ">=2.0.0" } serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/contextgraph-types/Cargo.toml b/contextgraph-types/Cargo.toml index f2ef144..27271b0 100644 --- a/contextgraph-types/Cargo.toml +++ b/contextgraph-types/Cargo.toml @@ -15,8 +15,21 @@ categories = ["encoding", "api-bindings"] # industry-facing artifact meant to be published on its own (issue #19). publish = true +[features] +# Off by default so the crate's "zero dependencies beyond serde" promise holds +# for the pure wire consumer. `ProvenanceAttestation` itself always compiles — +# only the hashing and signature checking need real cryptography. +default = [] +attestation = ["dep:sha2", "dep:ed25519-dalek"] + [dependencies] serde.workspace = true +sha2 = { workspace = true, optional = true } +ed25519-dalek = { workspace = true, optional = true } [dev-dependencies] serde_json.workspace = true + +[package.metadata.docs.rs] +# docs.rs renders the attestation surface; ordinary builds still opt in. +all-features = true diff --git a/contextgraph-types/src/attest.rs b/contextgraph-types/src/attest.rs new file mode 100644 index 0000000..9785fda --- /dev/null +++ b/contextgraph-types/src/attest.rs @@ -0,0 +1,1031 @@ +//! Provenance attestation — turning "we have a trace" into "we have evidence" +//! (`SPEC.md` §6.5, [ADR 0010](../../docs/adr/0010-provenance-attestation.md)). +//! +//! A [`Provenance`] link carries a `digest`. A digest is **tamper-evident only +//! to a party that already trusts whoever recorded it**: it proves the bytes +//! did not change *since someone wrote that number down*, and says nothing +//! about who wrote it or whether they were entitled to. For a host reading its +//! own cache that is enough. For the auditor asking "prove this citation is +//! what the provider actually served," it is not — the digest and the frame it +//! describes were produced by the same unauthenticated party, so a provider +//! that fabricates a frame simply fabricates a matching digest. +//! +//! A signature closes that gap, and it is the only thing that does. This module +//! defines the three constructions that make a frame's provenance verifiable +//! **offline**, by a third party, with no network and no trust in the host that +//! stored it: +//! +//! 1. A **provenance chain hash** ([`provenance_chain_head`]) — a hash chain +//! over a frame's ordered [`Provenance`] links, folded source-first, so no +//! link can be inserted, removed, reordered, or edited without changing the +//! head. +//! 2. A **frame commitment** ([`frame_commitment`]) — the chain head bound to +//! the frame's full [`FrameId`] identity. +//! 3. A **Merkle root** ([`merkle_root`]) over a whole result set, with +//! [`InclusionProof`]s, so one frame can be proven a member of a signed +//! answer without disclosing its siblings. +//! +//! [`ProvenanceAttestation`] is the detached Ed25519 signature over (1)–(3). +//! +//! # Why the frame identity is inside the signed preimage +//! +//! Signing a bare chain head would be a forgery primitive, not a defense. Two +//! frames citing the same source share a chain head, so a signature over the +//! head alone can be lifted from an innocuous frame and stapled onto a +//! fabricated one: the signature verifies, the evidence is invented. The signed +//! preimage therefore commits to `(provider_id, frame_id, content_digest)` — +//! the whole [`FrameId`] triple — *and* the chain head. A signature binds to one +//! frame served by one provider, or it binds to nothing. +//! +//! # Why the encoding is length-prefixed rather than canonical JSON +//! +//! The lifecycle profile's `record_hash` canonicalizes with RFC 8785 (JCS), +//! which is the right choice there: a record's hash covers a whole open-ended +//! JSON document. A provenance chain is a fixed list of six optional strings, +//! and for that shape JCS is a liability — it makes every implementation depend +//! on a conforming JSON canonicalizer, whose number formatting and Unicode +//! escaping rules are exactly where cross-language implementations silently +//! disagree. +//! +//! This module encodes the typed fields directly, each length-prefixed +//! (`SPEC.md` §6.5.1). Length prefixing is not decoration: +//! naive concatenation is ambiguous, and a chain with `uri: "ab", range: "c"` +//! would otherwise hash identically to one with `uri: "a", range: "bc"` — a +//! collision an adversary chooses, not one they have to find. A four-byte +//! big-endian length in front of every field makes the encoding injective, and +//! any language can produce it from the typed fields with no library at all. +//! +//! # Cryptography is optional; the preimage rule is not +//! +//! Hashing and signature verification live behind the off-by-default +//! `attestation` feature, so `contextgraph-types` keeps its "zero dependencies +//! beyond serde" promise for the pure wire consumer. [`ProvenanceAttestation`] +//! itself is a **wire type and always compiles** — a host must be able to parse, +//! relay, and store an attestation it has not been built to check, exactly as it +//! relays a frame kind it does not recognize. +//! +//! The protocol defines the *preimage*; it does not define your signing +//! backend. [`frame_commitment`] and [`merkle_root`] are public so a provider +//! holding keys in an HSM, a KMS, or a hardware token signs the bytes itself +//! and never hands this crate a secret. [`sign_frame_attestation`] exists for +//! providers and tests that are content to sign in-process. + +use serde::{Deserialize, Serialize}; + +use crate::frame::Provenance; + +/// The signature algorithm this revision defines. `algorithm` is a string, not +/// an enum, precisely so a post-quantum successor is an additive change rather +/// than a new major family — see [`ProvenanceAttestation::algorithm`]. +pub const ALGORITHM_ED25519: &str = "ed25519"; + +/// The domain-separation tags and Merkle prefixes the hashing rules use +/// (`SPEC.md` §6.5.1). Only referenced by the gated hashing code, but normative: +/// a reimplementation in another language must use these exact byte strings or +/// it will compute different commitments and interoperate with nothing. +#[cfg(feature = "attestation")] +mod domain { + /// Domain-separation tag for the hash-chain genesis. + pub(super) const GENESIS: &[u8] = b"contextgraph/attest/1/genesis"; + /// Domain-separation tag for one provenance link. + pub(super) const LINK: &[u8] = b"contextgraph/attest/1/link"; + /// Domain-separation tag for a frame commitment. + pub(super) const FRAME: &[u8] = b"contextgraph/attest/1/frame"; + /// Domain-separation tag for an empty Merkle tree. + pub(super) const MERKLE_EMPTY: &[u8] = b"contextgraph/attest/1/merkle-empty"; + /// RFC 6962 leaf prefix. Distinct from [`MERKLE_NODE`] so a leaf hash can + /// never be reinterpreted as an interior node — the second-preimage defense + /// that makes a Merkle proof mean what it claims. + pub(super) const MERKLE_LEAF: &[u8] = &[0x00]; + /// RFC 6962 interior-node prefix. + pub(super) const MERKLE_NODE: &[u8] = &[0x01]; +} + +/// A detached attestation binding one frame's provenance to a signing identity +/// (`SPEC.md` §6.5). +/// +/// **Detached, always.** Like the lifecycle profile's +/// [`RecordAttestation`](crate::RecordAttestation), this never travels inside +/// the preimage it signs. Re-signing after a key rotation, or a second attester +/// countersigning the same frame, must not perturb the frame's content-addressed +/// identity — and it cannot, because the attestation is metadata beside the +/// frame rather than a field within it. +/// +/// It is a **distinct type** from `RecordAttestation` even though five of six +/// fields match. The two sign different preimages under different domain tags, +/// and a shared type would invite the one mistake the domain separation exists +/// to prevent: presenting a record attestation as a frame attestation. The +/// cryptography already refuses that; the type system should make it unsayable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProvenanceAttestation { + /// The `sha256:` commitment this attestation signs — a + /// [`frame_commitment`] for a single frame, or a [`merkle_root`] for a + /// result set. + pub signed_commitment: String, + /// The signing key's id. Rotation is expressed by a new `key_id`, never by + /// reusing one, so an archived attestation always names the exact key that + /// produced it. + pub key_id: String, + /// The signature scheme, e.g. [`ALGORITHM_ED25519`]. + /// + /// A string rather than an enum: a verifier that does not recognize the + /// value returns [`AttestationVerdict::UnknownAlgorithm`] and declines, + /// which is a *safe* failure. Freezing the set into an enum would make + /// adopting a post-quantum scheme a breaking wire change, and this protocol + /// promises no flag day inside a major family. + pub algorithm: String, + /// The attesting authority — who is accountable for the claim, as distinct + /// from which key mechanically produced it. + pub attester_id: String, + /// The detached signature, lowercase hex. + /// + /// Hex rather than base64 to match the `sha256:` convention every other + /// digest in this protocol already uses; one encoding across the wire + /// surface is worth more than the 40 bytes base64 would save. + pub signature: String, + /// When the attestation was issued (a `SPEC.md` §F4 protocol timestamp). + pub issued_at: String, +} + +impl ProvenanceAttestation { + /// Build an attestation from its parts. + pub fn new( + signed_commitment: impl Into, + key_id: impl Into, + algorithm: impl Into, + attester_id: impl Into, + signature: impl Into, + issued_at: impl Into, + ) -> Self { + Self { + signed_commitment: signed_commitment.into(), + key_id: key_id.into(), + algorithm: algorithm.into(), + attester_id: attester_id.into(), + signature: signature.into(), + issued_at: issued_at.into(), + } + } + + /// Whether this attestation names a scheme this revision defines. + /// + /// Advisory: a verifier reports [`AttestationVerdict::UnknownAlgorithm`] + /// rather than treating an unrecognized scheme as a failure to *validate*. + /// The distinction matters to an auditor — "I cannot check this" is a + /// different finding from "this is forged." + pub fn uses_known_algorithm(&self) -> bool { + self.algorithm == ALGORITHM_ED25519 + } + + /// Whether `issued_at` is a well-formed protocol timestamp (`SPEC.md` §F4). + pub fn has_well_formed_issued_at(&self) -> bool { + crate::validate::is_protocol_timestamp(&self.issued_at) + } +} + +/// One step of a Merkle [`InclusionProof`]: the sibling hash, and which side it +/// sits on. +/// +/// RFC 6962 lets a verifier recover the side from index arithmetic. This carries +/// it explicitly instead. The redundancy costs one bool per step and removes an +/// entire class of verifier bug — an off-by-one in the index recursion produces +/// a *wrong root* rather than a silently-accepted proof, and a hand-written +/// verifier in another language is far likelier to get a stated side right than +/// to re-derive the split correctly. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InclusionStep { + /// The sibling subtree hash, `sha256:`. + pub sibling: String, + /// Whether the sibling is the **left** operand at this level. + pub sibling_is_left: bool, +} + +/// A proof that one frame commitment is a leaf of a signed [`merkle_root`] +/// (`SPEC.md` §6.5.3). +/// +/// This is what makes a signed answer *selectively* disclosable. A host that +/// served twelve frames can prove to an auditor that one specific frame was in +/// the signed set — and prove the provider committed to it before knowing which +/// one would be questioned — while disclosing nothing about the other eleven +/// beyond their hashes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InclusionProof { + /// The leaf's index in canonical order. + pub leaf_index: usize, + /// How many leaves the tree held. Part of the proof because a root alone + /// does not pin the tree's size, and a verifier that ignores it can be shown + /// a proof from a differently-shaped tree. + pub leaf_count: usize, + /// Sibling hashes from the leaf upward. + pub path: Vec, +} + +/// The outcome of checking a [`ProvenanceAttestation`] (`SPEC.md` §6.5.4). +/// +/// Every failure is *named*. A boolean would collapse "this signature is +/// forged" into "I was handed a truncated key," and those call for opposite +/// responses: the first is an incident, the second is a configuration bug. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AttestationVerdict { + /// The signature verifies against the recomputed commitment. + Valid, + /// The signature is well-formed and verifies, but over a *different* + /// commitment than this frame produces — the frame or its provenance was + /// altered after signing. The loudest possible finding. + CommitmentMismatch { + /// The commitment recomputed from the frame in hand. + expected: String, + /// The commitment the attestation claims to sign. + signed: String, + }, + /// The commitment matches but the signature does not verify under the + /// supplied key: a forgery, or the wrong key. + BadSignature, + /// The named algorithm is not one this build can check. Not a failure to + /// validate — a refusal to guess. + UnknownAlgorithm(String), + /// The public key was not a well-formed key for the named algorithm. + MalformedKey, + /// The signature field was not well-formed for the named algorithm. + MalformedSignature, + /// `signed_commitment` was not a well-formed `sha256:` digest. + MalformedCommitment, +} + +impl AttestationVerdict { + /// Whether this verdict is [`Valid`](Self::Valid). + /// + /// A host **MUST NOT** treat any other verdict as provisionally acceptable: + /// the point of an attestation is that "I could not check it" and "it is + /// good" are never the same answer. + pub fn is_valid(&self) -> bool { + matches!(self, Self::Valid) + } +} + +// --------------------------------------------------------------------------- +// Canonical encoding (`SPEC.md` §6.5.1) — dependency-free, so the rule is +// readable and reimplementable even in a build with `attestation` disabled. +// --------------------------------------------------------------------------- + +/// Append a length-prefixed string: `u32be(len) || utf8`. +fn enc_str(out: &mut Vec, s: &str) { + out.extend_from_slice(&(s.len() as u32).to_be_bytes()); + out.extend_from_slice(s.as_bytes()); +} + +/// Append a length-prefixed optional string: `0x00` for absent, `0x01 || +/// enc_str` for present. +/// +/// The presence byte is what keeps absent distinct from empty. Without it +/// `uri: None` and `uri: Some("")` would encode identically, and a provider +/// could drop a URI from a signed chain without disturbing the hash. +fn enc_opt(out: &mut Vec, s: Option<&str>) { + match s { + None => out.push(0x00), + Some(s) => { + out.push(0x01); + enc_str(out, s); + } + } +} + +/// The canonical encoding of one provenance link (`SPEC.md` §6.5.1). +/// +/// Field order is fixed by the struct's declaration order and pinned by the +/// spec — it is part of the normative rule, not an implementation detail, and +/// changing it is a breaking wire change. +pub fn encode_provenance_link(link: &Provenance) -> Vec { + let mut out = Vec::new(); + enc_str(&mut out, &link.kind); + enc_opt(&mut out, link.uri.as_deref()); + enc_opt(&mut out, link.range.as_deref()); + enc_opt(&mut out, link.digest.as_deref()); + enc_opt(&mut out, link.method.as_deref()); + enc_opt(&mut out, link.by.as_deref()); + out +} + +/// Render 32 raw bytes as this protocol's `sha256:` digest string. +pub fn digest_string(bytes: &[u8; 32]) -> String { + let mut s = String::with_capacity(7 + 64); + s.push_str("sha256:"); + for b in bytes { + // Lowercase hex, two chars per byte — the form `is_well_formed_digest` + // accepts and every other digest in the protocol already uses. + s.push(char::from_digit((b >> 4) as u32, 16).expect("nibble is < 16")); + s.push(char::from_digit((b & 0x0f) as u32, 16).expect("nibble is < 16")); + } + s +} + +/// Parse lowercase hex into bytes. `None` on any non-hex byte or odd length. +#[cfg(feature = "attestation")] +fn from_hex(s: &str) -> Option> { + if !s.len().is_multiple_of(2) { + return None; + } + let mut out = Vec::with_capacity(s.len() / 2); + let bytes = s.as_bytes(); + for pair in bytes.chunks_exact(2) { + let hi = (pair[0] as char).to_digit(16)?; + let lo = (pair[1] as char).to_digit(16)?; + out.push((hi * 16 + lo) as u8); + } + Some(out) +} + +/// Parse a `sha256:` digest string into its 32 raw bytes. +#[cfg(feature = "attestation")] +fn parse_digest(digest: &str) -> Option<[u8; 32]> { + let hex = digest.strip_prefix("sha256:")?; + let bytes = from_hex(hex)?; + bytes.try_into().ok() +} + +// --------------------------------------------------------------------------- +// Hashing and signing — gated, because they need real cryptography. +// --------------------------------------------------------------------------- + +#[cfg(feature = "attestation")] +mod crypto { + use super::*; + use crate::frame::ContextFrame; + use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; + use sha2::{Digest, Sha256}; + + /// SHA-256 over a sequence of parts, hashed in order without any separator + /// beyond the parts' own length prefixes. + fn sha256(parts: &[&[u8]]) -> [u8; 32] { + let mut hasher = Sha256::new(); + for part in parts { + hasher.update(part); + } + hasher.finalize().into() + } + + /// The head of a frame's provenance hash chain (`SPEC.md` §6.5.2). + /// + /// Links fold **source-first**, matching the order [`Provenance`] is + /// documented to carry (closest-to-source first), so each link commits to + /// everything nearer the source than itself: + /// + /// ```text + /// h₋₁ = SHA256(domain::GENESIS) + /// hᵢ = SHA256(domain::LINK ‖ hᵢ₋₁ ‖ encode(linkᵢ)) + /// head = hₙ₋₁ (or h₋₁ for an empty chain) + /// ``` + /// + /// Because every step consumes the previous head, no link can be inserted, + /// dropped, reordered, or edited without changing the result — which is the + /// property a bare per-link digest never had. An empty chain hashes to the + /// genesis value rather than to zero or to a sentinel, so "no provenance" is + /// a *stated* claim a signature can cover, not a gap. + pub fn provenance_chain_head(links: &[Provenance]) -> [u8; 32] { + let mut head = sha256(&[domain::GENESIS]); + for link in links { + let encoded = encode_provenance_link(link); + head = sha256(&[domain::LINK, &head, &encoded]); + } + head + } + + /// The commitment binding one frame's identity to its provenance chain + /// (`SPEC.md` §6.5.2) — the preimage a single-frame attestation signs. + /// + /// ```text + /// SHA256( + /// domain::FRAME ‖ enc(provider_id) ‖ enc(frame.id) + /// ‖ enc_opt(frame.content_digest) ‖ chain_head + /// ) + /// ``` + /// + /// `content_digest` is included so the signature covers the frame's *bytes*, + /// not merely its name: without it, a provider could re-serve different + /// content under the same frame id and the old signature would still check + /// out. It is an `Option` because a frame is permitted to declare no digest + /// — such a frame is unverifiable by design + /// (`docs/context-reuse.md` §4), and the encoding records that absence + /// honestly rather than substituting a placeholder. + pub fn frame_commitment(provider_id: &str, frame: &ContextFrame) -> [u8; 32] { + let chain_head = provenance_chain_head(&frame.provenance); + let mut preimage = Vec::new(); + enc_str(&mut preimage, provider_id); + enc_str(&mut preimage, &frame.id); + enc_opt(&mut preimage, frame.content_digest.as_deref()); + sha256(&[domain::FRAME, &preimage, &chain_head]) + } + + /// A Merkle leaf hash, RFC 6962 style: `SHA256(0x00 ‖ commitment)`. + fn leaf_hash(commitment: &[u8; 32]) -> [u8; 32] { + sha256(&[domain::MERKLE_LEAF, commitment]) + } + + /// A Merkle interior node, RFC 6962 style: `SHA256(0x01 ‖ left ‖ right)`. + fn node_hash(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { + sha256(&[domain::MERKLE_NODE, left, right]) + } + + /// The largest power of two strictly less than `n` (RFC 6962's split point). + /// Only meaningful for `n >= 2`. + fn split_point(n: usize) -> usize { + let mut k = 1; + while k * 2 < n { + k *= 2; + } + k + } + + /// The Merkle root over a set of frame commitments (`SPEC.md` §6.5.3). + /// + /// RFC 6962's tree shape, chosen over "duplicate the last leaf on an odd + /// level" because that shortcut admits two distinct leaf sets with the same + /// root — an ambiguity that is fine for a checksum and disqualifying for + /// evidence. Callers pass commitments in the protocol's canonical + /// [`FrameId`](crate::FrameId) order so the root is reproducible. + pub fn merkle_root(commitments: &[[u8; 32]]) -> [u8; 32] { + match commitments.len() { + 0 => sha256(&[domain::MERKLE_EMPTY]), + 1 => leaf_hash(&commitments[0]), + n => { + let k = split_point(n); + node_hash( + &merkle_root(&commitments[..k]), + &merkle_root(&commitments[k..]), + ) + } + } + } + + /// Build an [`InclusionProof`] for `leaf_index` within `commitments`. + /// `None` if the index is out of range. + pub fn inclusion_proof(commitments: &[[u8; 32]], leaf_index: usize) -> Option { + if leaf_index >= commitments.len() { + return None; + } + let mut path = Vec::new(); + collect_path(commitments, leaf_index, &mut path); + Some(InclusionProof { + leaf_index, + leaf_count: commitments.len(), + path, + }) + } + + /// Walk down the tree accumulating sibling hashes, leaf-upward. + fn collect_path(commitments: &[[u8; 32]], index: usize, path: &mut Vec) { + if commitments.len() <= 1 { + return; + } + let k = split_point(commitments.len()); + if index < k { + collect_path(&commitments[..k], index, path); + path.push(InclusionStep { + sibling: digest_string(&merkle_root(&commitments[k..])), + sibling_is_left: false, + }); + } else { + collect_path(&commitments[k..], index - k, path); + path.push(InclusionStep { + sibling: digest_string(&merkle_root(&commitments[..k])), + sibling_is_left: true, + }); + } + } + + /// Recompute a Merkle root from a leaf commitment and its proof. + /// + /// This is the whole offline story: an auditor holding one frame, its proof, + /// and a signed root needs nothing else — no network, no host, no provider. + /// `None` if any sibling in the path is malformed. + pub fn root_from_proof(commitment: &[u8; 32], proof: &InclusionProof) -> Option<[u8; 32]> { + if proof.leaf_index >= proof.leaf_count { + return None; + } + let mut acc = leaf_hash(commitment); + for step in &proof.path { + let sibling = parse_digest(&step.sibling)?; + acc = if step.sibling_is_left { + node_hash(&sibling, &acc) + } else { + node_hash(&acc, &sibling) + }; + } + Some(acc) + } + + /// Verify a detached attestation over a single frame (`SPEC.md` §6.5.4). + /// + /// Pure and offline. `public_key` is raw bytes rather than an + /// `ed25519_dalek` type on purpose: the public API of this crate names no + /// cryptography library, so the backend can be replaced — or a + /// post-quantum scheme added — without a breaking change to callers. + pub fn verify_frame_attestation( + provider_id: &str, + frame: &ContextFrame, + attestation: &ProvenanceAttestation, + public_key: &[u8], + ) -> AttestationVerdict { + let expected = frame_commitment(provider_id, frame); + verify_commitment(&expected, attestation, public_key) + } + + /// Verify a detached attestation over an already-computed commitment — a + /// [`merkle_root`] for a result set, or a [`frame_commitment`]. + pub fn verify_commitment( + expected: &[u8; 32], + attestation: &ProvenanceAttestation, + public_key: &[u8], + ) -> AttestationVerdict { + if attestation.algorithm != ALGORITHM_ED25519 { + return AttestationVerdict::UnknownAlgorithm(attestation.algorithm.clone()); + } + let Some(signed) = parse_digest(&attestation.signed_commitment) else { + return AttestationVerdict::MalformedCommitment; + }; + // Compare commitments *before* touching the signature. A mismatch means + // the frame changed after signing, and saying so is far more useful to + // an operator than the "bad signature" a naive order would report. + if signed != *expected { + return AttestationVerdict::CommitmentMismatch { + expected: digest_string(expected), + signed: attestation.signed_commitment.clone(), + }; + } + let Ok(key_bytes) = <[u8; 32]>::try_from(public_key) else { + return AttestationVerdict::MalformedKey; + }; + let Ok(verifying_key) = VerifyingKey::from_bytes(&key_bytes) else { + return AttestationVerdict::MalformedKey; + }; + let Some(sig_bytes) = from_hex(&attestation.signature) else { + return AttestationVerdict::MalformedSignature; + }; + let Ok(sig_bytes) = <[u8; 64]>::try_from(sig_bytes.as_slice()) else { + return AttestationVerdict::MalformedSignature; + }; + let signature = Signature::from_bytes(&sig_bytes); + // `verify_strict` rejects small-order public keys and the malleable + // signature forms `verify` tolerates. For evidence, the strict variant + // is the only defensible choice: a signature that two verifiers can + // disagree about is not evidence. + match verifying_key.verify_strict(&signed, &signature) { + Ok(()) => AttestationVerdict::Valid, + Err(_) => AttestationVerdict::BadSignature, + } + } + + /// Sign a frame's commitment in-process, for providers content to hold key + /// material in memory. + /// + /// A provider using an HSM or KMS instead calls [`frame_commitment`], + /// signs the 32 bytes with its own backend, and assembles the + /// [`ProvenanceAttestation`] by hand — the protocol specifies the preimage, + /// never the custody of the key. + pub fn sign_frame_attestation( + provider_id: &str, + frame: &ContextFrame, + signing_key_seed: &[u8; 32], + key_id: impl Into, + attester_id: impl Into, + issued_at: impl Into, + ) -> ProvenanceAttestation { + let commitment = frame_commitment(provider_id, frame); + sign_commitment( + &commitment, + signing_key_seed, + key_id, + attester_id, + issued_at, + ) + } + + /// Sign an arbitrary commitment (a frame commitment or a Merkle root). + pub fn sign_commitment( + commitment: &[u8; 32], + signing_key_seed: &[u8; 32], + key_id: impl Into, + attester_id: impl Into, + issued_at: impl Into, + ) -> ProvenanceAttestation { + let signing_key = SigningKey::from_bytes(signing_key_seed); + let signature = signing_key.sign(commitment); + let mut hex = String::with_capacity(128); + for b in signature.to_bytes() { + hex.push(char::from_digit((b >> 4) as u32, 16).expect("nibble is < 16")); + hex.push(char::from_digit((b & 0x0f) as u32, 16).expect("nibble is < 16")); + } + ProvenanceAttestation::new( + digest_string(commitment), + key_id, + ALGORITHM_ED25519, + attester_id, + hex, + issued_at, + ) + } + + /// The public key matching a signing seed, as raw bytes — the form + /// [`verify_frame_attestation`] accepts. + pub fn public_key_for(signing_key_seed: &[u8; 32]) -> [u8; 32] { + SigningKey::from_bytes(signing_key_seed) + .verifying_key() + .to_bytes() + } +} + +#[cfg(feature = "attestation")] +pub use crypto::{ + frame_commitment, inclusion_proof, merkle_root, provenance_chain_head, public_key_for, + root_from_proof, sign_commitment, sign_frame_attestation, verify_commitment, + verify_frame_attestation, +}; + +#[cfg(all(test, feature = "attestation"))] +mod tests { + use super::*; + use crate::frame::{ContextFrame, FrameKind}; + + /// A deterministic seed — tests need reproducible signatures, and this key + /// signs nothing outside this file. + const SEED: [u8; 32] = [7u8; 32]; + + fn link(kind: &str, uri: Option<&str>, digest: Option<&str>) -> Provenance { + Provenance { + kind: kind.into(), + uri: uri.map(Into::into), + range: None, + digest: digest.map(Into::into), + method: None, + by: None, + } + } + + fn frame_with(id: &str, provenance: Vec) -> ContextFrame { + let mut frame = ContextFrame::full(id, FrameKind::Doc, "Retry policy", "body", 0.9, 1); + frame.content_digest = Some("sha256:abcd".into()); + frame.provenance = provenance; + frame + } + + #[test] + fn the_encoding_is_injective_across_field_boundaries() { + // The attack length-prefixing exists to stop: without it, ("ab", "c") + // and ("a", "bc") concatenate to the same bytes and an adversary picks + // the collision rather than searching for one. + let a = link("file", Some("ab"), Some("c")); + let b = link("file", Some("a"), Some("bc")); + assert_ne!(encode_provenance_link(&a), encode_provenance_link(&b)); + } + + #[test] + fn an_absent_field_never_encodes_like_an_empty_one() { + let absent = link("file", None, None); + let empty = link("file", Some(""), None); + assert_ne!( + encode_provenance_link(&absent), + encode_provenance_link(&empty), + "the presence byte must keep None distinct from Some(\"\")" + ); + } + + #[test] + fn an_empty_chain_has_a_stated_head_not_a_zero() { + let head = provenance_chain_head(&[]); + assert_ne!(head, [0u8; 32], "\"no provenance\" is a claim, not a gap"); + // Stable across calls — the genesis is a constant, not a nonce. + assert_eq!(head, provenance_chain_head(&[])); + } + + #[test] + fn reordering_the_chain_changes_the_head() { + let a = link("file", Some("src/a.rs"), Some("sha256:aa")); + let b = link("derivation", Some("summary"), Some("sha256:bb")); + let forward = provenance_chain_head(&[a.clone(), b.clone()]); + let reversed = provenance_chain_head(&[b, a]); + assert_ne!( + forward, reversed, + "a hash chain must bind order; per-link digests never did" + ); + } + + #[test] + fn dropping_a_link_changes_the_head() { + let a = link("file", Some("src/a.rs"), Some("sha256:aa")); + let b = link("derivation", None, None); + assert_ne!( + provenance_chain_head(&[a.clone(), b]), + provenance_chain_head(&[a]), + "truncating provenance must be detectable" + ); + } + + #[test] + fn a_signed_frame_verifies_against_its_own_key() { + let frame = frame_with( + "f1", + vec![link("file", Some("src/a.rs"), Some("sha256:aa"))], + ); + let attestation = sign_frame_attestation( + "repo-graph", + &frame, + &SEED, + "key-1", + "oxagen", + "2026-08-27T00:00:00Z", + ); + let key = public_key_for(&SEED); + assert_eq!( + verify_frame_attestation("repo-graph", &frame, &attestation, &key), + AttestationVerdict::Valid + ); + assert!(attestation.uses_known_algorithm()); + assert!(attestation.has_well_formed_issued_at()); + } + + #[test] + fn editing_provenance_after_signing_is_caught_as_a_mismatch() { + let frame = frame_with( + "f1", + vec![link("file", Some("src/a.rs"), Some("sha256:aa"))], + ); + let attestation = sign_frame_attestation( + "repo-graph", + &frame, + &SEED, + "key-1", + "oxagen", + "2026-08-27T00:00:00Z", + ); + // Rewrite the source URI — the exact tamper a bare digest cannot see, + // because the tamperer simply rewrites the digest too. + let mut tampered = frame.clone(); + tampered.provenance[0].uri = Some("src/evil.rs".into()); + tampered.provenance[0].digest = Some("sha256:ff".into()); + + let key = public_key_for(&SEED); + let verdict = verify_frame_attestation("repo-graph", &tampered, &attestation, &key); + assert!( + matches!(verdict, AttestationVerdict::CommitmentMismatch { .. }), + "expected a commitment mismatch, got {verdict:?}" + ); + assert!(!verdict.is_valid()); + } + + #[test] + fn a_signature_cannot_be_lifted_onto_another_frame() { + // The forgery the FrameId binding exists to prevent. Both frames cite + // exactly the same source, so they share a chain head; only the identity + // binding distinguishes them. + let shared = vec![link("file", Some("src/a.rs"), Some("sha256:aa"))]; + let honest = frame_with("f1", shared.clone()); + let forged = frame_with("f2", shared); + assert_eq!( + provenance_chain_head(&honest.provenance), + provenance_chain_head(&forged.provenance), + "precondition: identical provenance means an identical chain head" + ); + + let attestation = sign_frame_attestation( + "repo-graph", + &honest, + &SEED, + "key-1", + "oxagen", + "2026-08-27T00:00:00Z", + ); + let key = public_key_for(&SEED); + assert!( + matches!( + verify_frame_attestation("repo-graph", &forged, &attestation, &key), + AttestationVerdict::CommitmentMismatch { .. } + ), + "a stolen signature must not validate a different frame" + ); + } + + #[test] + fn the_same_frame_from_another_provider_does_not_verify() { + let frame = frame_with("f1", vec![link("file", Some("src/a.rs"), None)]); + let attestation = sign_frame_attestation( + "repo-graph", + &frame, + &SEED, + "key-1", + "oxagen", + "2026-08-27T00:00:00Z", + ); + let key = public_key_for(&SEED); + assert!( + matches!( + verify_frame_attestation("impostor", &frame, &attestation, &key), + AttestationVerdict::CommitmentMismatch { .. } + ), + "the provider id is part of the signed identity" + ); + } + + #[test] + fn re_serving_different_bytes_under_the_same_id_is_caught() { + let frame = frame_with("f1", vec![link("file", Some("src/a.rs"), None)]); + let attestation = sign_frame_attestation( + "repo-graph", + &frame, + &SEED, + "key-1", + "oxagen", + "2026-08-27T00:00:00Z", + ); + let mut swapped = frame.clone(); + swapped.content_digest = Some("sha256:0000".into()); + let key = public_key_for(&SEED); + assert!( + matches!( + verify_frame_attestation("repo-graph", &swapped, &attestation, &key), + AttestationVerdict::CommitmentMismatch { .. } + ), + "the signature covers the frame's bytes, not just its name" + ); + } + + #[test] + fn a_wrong_key_is_a_bad_signature_not_a_mismatch() { + let frame = frame_with("f1", vec![]); + let attestation = sign_frame_attestation( + "repo-graph", + &frame, + &SEED, + "key-1", + "oxagen", + "2026-08-27T00:00:00Z", + ); + let other = public_key_for(&[9u8; 32]); + assert_eq!( + verify_frame_attestation("repo-graph", &frame, &attestation, &other), + AttestationVerdict::BadSignature, + "the commitment is intact; only the key is wrong" + ); + } + + #[test] + fn an_unknown_algorithm_is_declined_rather_than_failed() { + let frame = frame_with("f1", vec![]); + let mut attestation = sign_frame_attestation( + "repo-graph", + &frame, + &SEED, + "key-1", + "oxagen", + "2026-08-27T00:00:00Z", + ); + attestation.algorithm = "dilithium3".into(); + let key = public_key_for(&SEED); + let verdict = verify_frame_attestation("repo-graph", &frame, &attestation, &key); + assert_eq!( + verdict, + AttestationVerdict::UnknownAlgorithm("dilithium3".into()) + ); + assert!(!verdict.is_valid(), "declining is still not accepting"); + assert!(!attestation.uses_known_algorithm()); + } + + #[test] + fn malformed_keys_and_signatures_are_named_distinctly() { + let frame = frame_with("f1", vec![]); + let attestation = sign_frame_attestation( + "repo-graph", + &frame, + &SEED, + "key-1", + "oxagen", + "2026-08-27T00:00:00Z", + ); + assert_eq!( + verify_frame_attestation("repo-graph", &frame, &attestation, &[0u8; 5]), + AttestationVerdict::MalformedKey + ); + + let mut truncated = attestation.clone(); + truncated.signature = "abcd".into(); + assert_eq!( + verify_frame_attestation("repo-graph", &frame, &truncated, &public_key_for(&SEED)), + AttestationVerdict::MalformedSignature + ); + + let mut bad_commitment = attestation; + bad_commitment.signed_commitment = "not-a-digest".into(); + assert_eq!( + verify_frame_attestation( + "repo-graph", + &frame, + &bad_commitment, + &public_key_for(&SEED) + ), + AttestationVerdict::MalformedCommitment + ); + } + + #[test] + fn an_attestation_round_trips_through_json() { + let frame = frame_with("f1", vec![link("file", Some("a"), None)]); + let attestation = sign_frame_attestation( + "repo-graph", + &frame, + &SEED, + "key-1", + "oxagen", + "2026-08-27T00:00:00Z", + ); + let json = serde_json::to_string(&attestation).unwrap(); + let back: ProvenanceAttestation = serde_json::from_str(&json).unwrap(); + assert_eq!(back, attestation); + } + + #[test] + fn every_leaf_of_a_signed_set_proves_its_own_membership() { + let commitments: Vec<[u8; 32]> = (0..7) + .map(|i| frame_commitment("repo-graph", &frame_with(&format!("f{i}"), vec![]))) + .collect(); + let root = merkle_root(&commitments); + + for (index, commitment) in commitments.iter().enumerate() { + let proof = inclusion_proof(&commitments, index).expect("index is in range"); + assert_eq!(proof.leaf_index, index); + assert_eq!(proof.leaf_count, 7); + assert_eq!( + root_from_proof(commitment, &proof), + Some(root), + "leaf {index} must recompute the signed root" + ); + } + } + + #[test] + fn a_proof_does_not_validate_a_commitment_that_was_not_in_the_set() { + let commitments: Vec<[u8; 32]> = (0..4) + .map(|i| frame_commitment("repo-graph", &frame_with(&format!("f{i}"), vec![]))) + .collect(); + let root = merkle_root(&commitments); + let proof = inclusion_proof(&commitments, 1).unwrap(); + + let outsider = frame_commitment("repo-graph", &frame_with("intruder", vec![])); + assert_ne!( + root_from_proof(&outsider, &proof), + Some(root), + "an unsigned frame must not ride someone else's proof" + ); + } + + #[test] + fn a_single_frame_set_still_produces_a_usable_proof() { + let commitments = vec![frame_commitment("repo-graph", &frame_with("only", vec![]))]; + let root = merkle_root(&commitments); + let proof = inclusion_proof(&commitments, 0).unwrap(); + assert!(proof.path.is_empty(), "a lone leaf needs no siblings"); + assert_eq!(root_from_proof(&commitments[0], &proof), Some(root)); + } + + #[test] + fn an_empty_set_has_a_distinct_root() { + let empty = merkle_root(&[]); + let lone = merkle_root(&[frame_commitment("repo-graph", &frame_with("only", vec![]))]); + assert_ne!(empty, lone); + assert!(inclusion_proof(&[], 0).is_none()); + } + + #[test] + fn leaf_and_node_hashing_are_domain_separated() { + // Without the RFC 6962 prefixes, an interior node's hash could be + // presented as a leaf, letting a subtree masquerade as a single frame. + let a = frame_commitment("repo-graph", &frame_with("a", vec![])); + let b = frame_commitment("repo-graph", &frame_with("b", vec![])); + let pair_root = merkle_root(&[a, b]); + // The two-leaf root must not equal the one-leaf root of any commitment. + assert_ne!(pair_root, merkle_root(&[a])); + assert_ne!(pair_root, merkle_root(&[b])); + } + + #[test] + fn a_signed_merkle_root_verifies_for_the_whole_result_set() { + let commitments: Vec<[u8; 32]> = (0..3) + .map(|i| frame_commitment("repo-graph", &frame_with(&format!("f{i}"), vec![]))) + .collect(); + let root = merkle_root(&commitments); + let attestation = sign_commitment(&root, &SEED, "key-1", "oxagen", "2026-08-27T00:00:00Z"); + let key = public_key_for(&SEED); + assert_eq!( + verify_commitment(&root, &attestation, &key), + AttestationVerdict::Valid + ); + } + + #[test] + fn digest_strings_are_well_formed_protocol_digests() { + let head = provenance_chain_head(&[link("file", Some("a"), None)]); + let rendered = digest_string(&head); + assert!( + crate::validate::is_well_formed_digest(&rendered), + "{rendered} must satisfy the protocol digest grammar" + ); + } +} diff --git a/contextgraph-types/src/frame.rs b/contextgraph-types/src/frame.rs index ebedddc..aed2c08 100644 --- a/contextgraph-types/src/frame.rs +++ b/contextgraph-types/src/frame.rs @@ -19,9 +19,54 @@ use crate::identity::FrameId; use crate::token::budget_tokens; use crate::validate::{is_protocol_timestamp, is_well_formed_digest}; -/// What kind of thing a frame represents. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] +/// The wire strings of the seven base frame kinds. +const KIND_SNIPPET: &str = "snippet"; +const KIND_SYMBOL: &str = "symbol"; +const KIND_FACT: &str = "fact"; +const KIND_DOC: &str = "doc"; +const KIND_MEMORY: &str = "memory"; +const KIND_EPISODE: &str = "episode"; +const KIND_GRAPH: &str = "graph"; + +/// What kind of thing a frame represents (`SPEC.md` §6). +/// +/// # Why this is not a closed enum +/// +/// The protocol guarantees no flag day inside a major family: a `contextgraph/1.0` +/// host and a `contextgraph/1.1` host interoperate, and minor versions may add +/// vocabulary. A closed enum contradicts that guarantee twice over. A frame +/// carrying a kind introduced in 1.1 would fail to **deserialize** on a 1.0 +/// host — not degrade, *fail* — and every exhaustive `match` in downstream Rust +/// would break the day a variant was added. +/// +/// So `FrameKind` follows the same shape as [`EgressScope`](crate::EgressScope): +/// a closed base vocabulary of seven kinds, plus an +/// [`Unknown`](Self::Unknown) variant that **preserves the original string**. +/// A host that does not recognize a kind can still parse the frame, route it, +/// budget it, cite it, and re-serialize it byte-identically. +/// +/// That last property is why `Unknown` carries a `String` rather than being a +/// bare unit variant with `#[serde(other)]`. `#[serde(other)]` collapses every +/// unrecognized value into one variant and *discards* the original, so a host +/// relaying a frame it did not fully understand would silently rewrite +/// `"kind": "trajectory"` to something else on the way out. A forward-compat +/// mechanism that corrupts data in a relay is worse than the failure it +/// replaces. +/// +/// # `#[non_exhaustive]` +/// +/// The attribute forces downstream `match` expressions to carry a wildcard arm, +/// which makes every *future* kind addition a non-breaking change for every +/// consumer. It is a one-time cost paid now so the version promise holds +/// forever after. +/// +/// # Not `Copy` +/// +/// Preserving an unknown kind's string means the type owns an allocation, so it +/// cannot be `Copy`. Forward compatibility is a protocol guarantee; `Copy` was +/// an ergonomic convenience. When the two conflict the guarantee wins. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[non_exhaustive] pub enum FrameKind { Snippet, Symbol, @@ -30,6 +75,82 @@ pub enum FrameKind { Memory, Episode, Graph, + /// A kind this revision does not define — most likely one introduced by a + /// later minor version of the same major family. The original wire string + /// is retained verbatim so the frame round-trips unchanged. + /// + /// A host **MUST NOT** reject a frame for carrying an unknown kind. It may + /// decline to *specialize* its handling — that is a rendering decision, not + /// a validity one. + Unknown(String), +} + +impl FrameKind { + /// The canonical wire string of this kind. + pub fn as_str(&self) -> &str { + match self { + Self::Snippet => KIND_SNIPPET, + Self::Symbol => KIND_SYMBOL, + Self::Fact => KIND_FACT, + Self::Doc => KIND_DOC, + Self::Memory => KIND_MEMORY, + Self::Episode => KIND_EPISODE, + Self::Graph => KIND_GRAPH, + Self::Unknown(kind) => kind, + } + } + + /// Parse a wire string: a known base name maps to its variant, anything + /// else to [`Unknown`](Self::Unknown). Never fails — an unrecognized kind + /// is a frame a host cannot specialize, not a frame it must refuse. + pub fn from_wire(kind: impl Into) -> Self { + let kind = kind.into(); + match kind.as_str() { + KIND_SNIPPET => Self::Snippet, + KIND_SYMBOL => Self::Symbol, + KIND_FACT => Self::Fact, + KIND_DOC => Self::Doc, + KIND_MEMORY => Self::Memory, + KIND_EPISODE => Self::Episode, + KIND_GRAPH => Self::Graph, + _ => Self::Unknown(kind), + } + } + + /// Whether this kind is one of the seven this revision defines. + pub fn is_known(&self) -> bool { + !matches!(self, Self::Unknown(_)) + } + + /// Every kind this revision names — a registry, not a restriction. + pub const KNOWN: &'static [&'static str] = &[ + KIND_SNIPPET, + KIND_SYMBOL, + KIND_FACT, + KIND_DOC, + KIND_MEMORY, + KIND_EPISODE, + KIND_GRAPH, + ]; +} + +impl std::fmt::Display for FrameKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl Serialize for FrameKind { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for FrameKind { + fn deserialize>(deserializer: D) -> Result { + let kind = String::deserialize(deserializer)?; + Ok(Self::from_wire(kind)) + } } /// How a frame carries its content @@ -839,4 +960,69 @@ mod tests { frame.content = Some("summary…".into()); assert!(frame.representation_invariants().is_ok()); } + + #[test] + fn every_known_kind_round_trips_through_its_canonical_wire_string() { + for (kind, wire) in [ + (FrameKind::Snippet, "snippet"), + (FrameKind::Symbol, "symbol"), + (FrameKind::Fact, "fact"), + (FrameKind::Doc, "doc"), + (FrameKind::Memory, "memory"), + (FrameKind::Episode, "episode"), + (FrameKind::Graph, "graph"), + ] { + assert_eq!(kind.as_str(), wire); + let json = serde_json::to_string(&kind).unwrap(); + assert_eq!(json, format!("\"{wire}\"")); + let back: FrameKind = serde_json::from_str(&json).unwrap(); + assert_eq!(back, kind); + assert!(kind.is_known()); + } + } + + #[test] + fn a_kind_from_a_later_minor_version_deserializes_instead_of_failing() { + // The bug this variant exists for: before it, a `contextgraph/1.1` frame + // carrying a kind added in 1.1 made a 1.0 host fail deserialization + // outright — which contradicts the protocol's own promise of no flag day + // inside a major family. + let back: FrameKind = serde_json::from_str("\"trajectory\"").unwrap(); + assert_eq!(back, FrameKind::Unknown("trajectory".into())); + assert!(!back.is_known()); + } + + #[test] + fn an_unknown_kind_re_serializes_byte_identically() { + // Why `Unknown(String)` and not `#[serde(other)]`: a relaying host must + // hand on exactly what it received. Discarding the string would make a + // 1.0 host silently rewrite a 1.1 frame it was merely passing through — + // corruption dressed up as forward compatibility. + let json = "\"trajectory\""; + let kind: FrameKind = serde_json::from_str(json).unwrap(); + assert_eq!(serde_json::to_string(&kind).unwrap(), json); + } + + #[test] + fn a_whole_frame_with_an_unknown_kind_survives_a_round_trip() { + let wire = r#"{"id":"f1","kind":"trajectory","title":"Run 12","content":"…","score":0.5,"token_cost":1}"#; + let frame: ContextFrame = serde_json::from_str(wire).unwrap(); + assert_eq!(frame.kind, FrameKind::Unknown("trajectory".into())); + // Everything else still works: a host can budget, order, and cite a + // frame whose kind it cannot specialize. + assert!(frame.has_valid_score()); + let back: ContextFrame = + serde_json::from_str(&serde_json::to_string(&frame).unwrap()).unwrap(); + assert_eq!(back, frame); + } + + #[test] + fn an_unknown_kind_never_collides_with_a_known_one() { + assert_eq!(FrameKind::from_wire("doc"), FrameKind::Doc); + assert_ne!(FrameKind::Unknown("doc".into()), FrameKind::Doc); + for known in FrameKind::KNOWN { + assert!(FrameKind::from_wire(*known).is_known(), "{known}"); + } + assert_eq!(FrameKind::KNOWN.len(), 7); + } } diff --git a/contextgraph-types/src/lib.rs b/contextgraph-types/src/lib.rs index c443334..88f06f0 100644 --- a/contextgraph-types/src/lib.rs +++ b/contextgraph-types/src/lib.rs @@ -10,6 +10,7 @@ //! //! Protocol version: `contextgraph/1.0`. +pub mod attest; pub mod attribution; pub mod capability; pub mod consent; @@ -24,6 +25,16 @@ pub mod usage; pub mod validate; pub mod verify; +pub use attest::{ + ALGORITHM_ED25519, AttestationVerdict, InclusionProof, InclusionStep, ProvenanceAttestation, + digest_string, encode_provenance_link, +}; +#[cfg(feature = "attestation")] +pub use attest::{ + frame_commitment, inclusion_proof, merkle_root, provenance_chain_head, public_key_for, + root_from_proof, sign_commitment, sign_frame_attestation, verify_commitment, + verify_frame_attestation, +}; pub use attribution::{AttributionReport, ContextUse}; pub use capability::{ Capabilities, DataFlow, ProviderInfo, QueryCapability, embedding_fingerprints_match, diff --git a/contextgraph-types/tests/attestation_vectors.rs b/contextgraph-types/tests/attestation_vectors.rs new file mode 100644 index 0000000..54403d2 --- /dev/null +++ b/contextgraph-types/tests/attestation_vectors.rs @@ -0,0 +1,129 @@ +//! Cross-language reference vectors for provenance attestation +//! (`SPEC.md` §6.5). +//! +//! §6.5.1 defines a **normative** byte encoding, and a normative encoding with +//! no published vectors is a rule two implementations can both believe they +//! follow while computing different hashes. These are the byte vectors an +//! implementation in any language reconciles against; if your chain head +//! matches these, your encoding matches the spec. +//! +//! Every value here is a *fixture*, not an assertion about the current code: +//! changing the encoding changes these digests, and a diff in this file is a +//! **wire-breaking change** that requires a new major family (`SPEC.md` §15). +//! That is exactly why they are written out rather than recomputed. + +#![cfg(feature = "attestation")] + +use contextgraph_types::attest::{ + digest_string, encode_provenance_link, frame_commitment, merkle_root, provenance_chain_head, +}; +use contextgraph_types::{ContextFrame, FrameKind, Provenance}; + +/// The empty chain — "this frame claims no provenance" as a signed assertion +/// rather than a gap. +const EMPTY_CHAIN_HEAD: &str = + "sha256:2ab226c884ffeb5ac95d300a8d8ee726cb4488fb59a19047d21da7170e9b63d6"; + +fn file_link() -> Provenance { + Provenance { + kind: "file".into(), + uri: Some("src/retry.rs".into()), + range: Some("L10-L20".into()), + digest: Some(format!("sha256:{}", "ab".repeat(32))), + method: None, + by: None, + } +} + +fn derivation_link() -> Provenance { + Provenance { + kind: "derivation".into(), + uri: None, + range: None, + digest: None, + method: Some("extractive_summary".into()), + by: Some("refprov/1.0".into()), + } +} + +#[test] +fn the_link_encoding_is_length_prefixed_exactly_as_specified() { + let link = Provenance { + kind: "file".into(), + uri: Some("a".into()), + range: None, + digest: None, + method: None, + by: None, + }; + // kind="file" -> 00 00 00 04 'f' 'i' 'l' 'e' + // uri=Some("a") -> 01 00 00 00 01 'a' + // range..by = None -> 00 00 00 00 + let expected: Vec = vec![ + 0x00, 0x00, 0x00, 0x04, b'f', b'i', b'l', b'e', // + 0x01, 0x00, 0x00, 0x00, 0x01, b'a', // + 0x00, 0x00, 0x00, 0x00, + ]; + assert_eq!( + encode_provenance_link(&link), + expected, + "the §6.5.1 encoding is normative — a mismatch here is a wire break" + ); +} + +#[test] +fn an_empty_provenance_chain_hashes_to_the_published_genesis() { + assert_eq!(digest_string(&provenance_chain_head(&[])), EMPTY_CHAIN_HEAD); +} + +#[test] +fn the_published_chain_vectors_hold() { + assert_eq!( + digest_string(&provenance_chain_head(&[file_link()])), + "sha256:ac5418d723088033179a2671d17cd08d3e082eefa783e3eeb1a5145f83592178" + ); + assert_eq!( + digest_string(&provenance_chain_head(&[file_link(), derivation_link()])), + "sha256:2245cb46807d55e9e89479b2948d7887fbba95430dd315d63972744a47a18410" + ); +} + +#[test] +fn the_published_frame_commitment_vector_holds() { + let mut frame = ContextFrame::full( + "retry-policy", + FrameKind::Doc, + "Retry policy", + "Retries use exponential backoff.", + 0.87, + 8, + ); + frame.content_digest = Some(format!("sha256:{}", "cd".repeat(32))); + frame.provenance = vec![file_link()]; + + assert_eq!( + digest_string(&frame_commitment("repo-graph", &frame)), + "sha256:cba5cb083177e61ce370ab586dbf7096508e5224ad18d213c902e014796c88ea" + ); +} + +#[test] +fn the_published_merkle_vectors_hold() { + let leaves: Vec<[u8; 32]> = (0..4) + .map(|i| { + let mut frame = + ContextFrame::full(format!("f{i}"), FrameKind::Doc, "T", "body", 0.5, 1); + frame.content_digest = Some(format!("sha256:{}", "0e".repeat(32))); + frame_commitment("repo-graph", &frame) + }) + .collect(); + + assert_eq!( + digest_string(&merkle_root(&[])), + "sha256:1d7896160c99d216069525861650366067ae242de9ee5e86730a36c80e57bcc2" + ); + assert_eq!( + digest_string(&merkle_root(&leaves)), + "sha256:a29d4042e68ee59dd8713ec5361031add15e4814353da0bfca7a5bf59a96c869" + ); +} diff --git a/docs/adr/0010-provenance-attestation.md b/docs/adr/0010-provenance-attestation.md new file mode 100644 index 0000000..4ba52a2 --- /dev/null +++ b/docs/adr/0010-provenance-attestation.md @@ -0,0 +1,191 @@ +# 0010 — Provenance attestation: signing the chain, not just digesting it + +**Status:** Accepted (`contextgraph/1.0`, additive) + +## Context + +Provenance integrity has been a headline guarantee since the first draft, and +`SPEC.md` §6.2 (F5) makes it checkable: a `file` provenance link **MUST** carry +`sha256:<64 lowercase hex>`, and the host can re-read the addressed bytes and +compare. + +That construction detects **drift**. It does not establish **evidence**, and the +distinction has been quietly load-bearing in how this protocol is positioned. + +A digest is tamper-evident only to a party that already trusts whoever recorded +it. It proves the bytes have not changed since someone wrote that number down — +it says nothing about who wrote it, or whether they were entitled to. The digest +and the frame it describes are produced by the same unauthenticated party, so a +provider willing to fabricate a frame is equally willing to fabricate its +digest, and every check in §6.2 passes. F5-bytes narrows this for the one case +where the host can independently re-read a source it already trusts, but that +covers `file` provenance on the host's own disk and nothing else: not a +`derivation` link, not an `episode`, not a remote provider's claim about a +corpus the host has never seen. + +Two questions therefore had no answer: + +1. **"Prove this citation is what the provider actually served."** An auditor + holding a stored frame months later cannot distinguish a genuine one from a + fabrication, because both are internally consistent. +2. **"Prove this frame was in the answer, without showing me the rest."** A host + that served twelve frames had no way to substantiate one of them in + isolation. + +The gap is a signature. Nothing else closes it. Comparable systems reached the +same conclusion — `world-model-mcp` signs events with Ed25519 and Merkle-chains +them for offline verification — and for the audit and regulated-deployment +buyers, "we have a trace" and "we have evidence" are different products +separated by exactly this. + +The lifecycle profile already anticipated the shape: `RecordAttestation` +(reconciliation row C5) is a detached Ed25519 signature over `record_hash`. But +it was a **declared type with no implementation** — the workspace carried no +signing or verification code and no cryptographic dependency at all — and it +covers the *record* layer, not the *frame* provenance chain that `context/query` +returns. + +## Decision + +Define provenance attestation at the frame layer, in three constructions, +specified normatively in `SPEC.md` §6.5 and implemented in +`contextgraph_types::attest`. + +### 1. A hash chain, not a set of independent digests + +Provenance links fold source-first into a hash chain, each step consuming the +previous head. A set of per-link digests binds each link's *content* but not the +chain's *shape*: links could be inserted, dropped, or reordered with every +individual digest still checking out. Truncating a provenance chain — dropping +the derivation step that would reveal a frame was summarized rather than quoted +— was undetectable. A chain makes it a hash mismatch. + +An empty chain hashes to a domain-separated genesis value rather than to zero, +so "this frame claims no provenance" becomes a signed assertion rather than a +gap in coverage. + +### 2. The signed preimage binds the frame's identity + +The commitment covers the `(provider_id, frame_id, content_digest)` triple of +§6.3 **in addition to** the chain head. + +This is not defensive detail; without it the scheme is a forgery primitive. Two +frames citing the same source produce the same chain head, so a signature over +the head alone can be lifted from an innocuous frame and stapled onto a +fabricated one — it verifies, and the evidence is invented. Including +`content_digest` further means the signature covers the frame's *bytes*, not +merely its name, so a provider cannot re-serve different content under a +previously signed frame id. + +### 3. A length-prefixed encoding, not RFC 8785 (JCS) + +The lifecycle profile canonicalizes `record_hash` with JCS, and that remains +right *there*: a record's hash covers an open-ended JSON document. + +A provenance link is six optional strings, and for that shape JCS is a +liability. It makes every implementation depend on a conforming JSON +canonicalizer, whose number-formatting and Unicode-escaping rules are precisely +where cross-language implementations diverge silently. This protocol's whole +posture is that a provider in any language should be implementable from the spec +alone; requiring a JCS implementation to compute one hash contradicts that. + +The encoding therefore serializes the typed fields directly, each length- +prefixed. Length prefixing is what makes the encoding injective: bare +concatenation is ambiguous, and without prefixes a link with `uri: "ab", +range: "c"` encodes identically to one with `uri: "a", range: "bc"` — a +collision an adversary *chooses* rather than searches for. The presence byte +distinguishing `None` from `Some("")` is load-bearing for the same reason. + +### 4. RFC 6962 Merkle trees for result sets + +A provider signing a whole answer commits to a Merkle root over frame +commitments in canonical order, with inclusion proofs. RFC 6962's distinct leaf +(`0x00`) and interior (`0x01`) prefixes prevent an interior node's hash from +being presented as a leaf. + +The common "duplicate the last leaf on an odd level" shortcut is rejected: it +admits two distinct leaf sets with the same root. That ambiguity is tolerable in +a checksum and disqualifying in evidence. + +Inclusion proofs carry the leaf count as well as the index, because a root alone +does not pin the tree's size and a verifier that ignores it can be shown a proof +from a differently-shaped tree. + +### 5. Cryptography is optional; the preimage rule is not + +Hashing and signature verification sit behind an off-by-default `attestation` +feature. `contextgraph-types` is published as "zero dependencies beyond serde", +and that promise is a real adoption argument for the crate that every +implementer ports from — an unconditional `ed25519-dalek` would spend it. + +`ProvenanceAttestation` itself always compiles. A host must be able to parse, +relay, and store an attestation it was not built to check, for the same reason +it must relay a frame kind it does not recognize (§13 U2). + +### 6. The protocol defines the preimage, not key custody + +`frame_commitment` and `merkle_root` are public, so a provider holding keys in +an HSM, a KMS, or a hardware token signs the 32 bytes with its own backend and +never hands this crate a secret. `sign_frame_attestation` exists for providers +content to sign in-process. + +`algorithm` is a string rather than an enum, so adopting a post-quantum scheme +is an additive change; a verifier that does not recognize it reports +`UnknownAlgorithm` and declines. + +### 7. Verdicts are named, and unverifiable ≠ invalid + +Verification returns a named verdict, not a boolean. "This frame was altered +after signing" (`CommitmentMismatch`), "the signature is forged or the key is +wrong" (`BadSignature`), and "I was handed a truncated key" (`MalformedKey`) call +for entirely different responses; collapsing them sends an operator hunting a +key-management bug when the finding is tampering. The commitment is compared +*before* the signature so the more informative failure is the one reported. + +F9 requires that an unverifiable attestation degrade a frame to *unattested* +rather than disqualify it. A host that dropped such frames would hand any peer a +denial-of-service primitive: attach a malformed attestation, watch the evidence +disappear. + +## Alternatives considered + +**Do nothing; F5 digests are enough.** Rejected. They answer a different +question, and the positioning around audit and provenance is currently writing +cheques the construction cannot cash. + +**Reuse `RecordAttestation` for frames.** Rejected. Five of six fields match, but +the two sign different preimages, and a shared type invites exactly the confusion +the domain separation exists to prevent — presenting a record attestation as a +frame attestation. The cryptography already refuses it; the type system should +make it unsayable. + +**Put the attestation inside the frame.** Rejected. It would perturb the frame's +content-addressed identity on every re-signing and key rotation, and make +countersigning impossible. Detached, as the record layer already established. + +**A new `contextgraph-attest` crate rather than a feature.** Rejected. The +preimage is coupled to the wire types' field set — if `Provenance` gains a field +the encoding must change in lockstep — and a separate crate lets the two version +independently, which is how a cross-language hash rule silently forks. A feature +flag keeps one canonical definition and compile-checked coupling while +preserving the zero-dependency default. + +**Mandate signing.** Rejected for this revision. Attestation is optional: a +conformant provider may serve none, and a conformant host may verify none. What +is not optional is the *construction* — an attestation that exists must be +computed exactly one way, or two implementations will disagree about whether the +same evidence is genuine. + +## Consequences + +- `contextgraph-types` gains an optional `attestation` feature + (`sha2`, `ed25519-dalek`). The default dependency set is unchanged: serde only. +- `SPEC.md` gains §6.5 and requirements F6–F9. +- `contextgraph-types/tests/attestation_vectors.rs` publishes the byte vectors a + reimplementation in another language reconciles against. A diff in that file is + a wire-breaking change requiring a new major family. +- Not yet done, and tracked as follow-up work: host-side verification wired into + composition, an `attestation` conformance check with an adversarial + `--misbehave` mode, attestations carried in the `frames` envelope and the JSON + Schema, and key distribution/rotation — which is deliberately out of scope + here, since the protocol specifies the preimage and not the PKI. diff --git a/docs/adr/0011-open-frame-kind-vocabulary.md b/docs/adr/0011-open-frame-kind-vocabulary.md new file mode 100644 index 0000000..24991d4 --- /dev/null +++ b/docs/adr/0011-open-frame-kind-vocabulary.md @@ -0,0 +1,109 @@ +# 0011 — `FrameKind` is an open vocabulary + +**Status:** Accepted (`contextgraph/1.0`; Rust-semver breaking, wire-compatible) + +## Context + +`SPEC.md` §3.1 promises that two versions sharing a major family interoperate: +`contextgraph/1.0` and `contextgraph/1.1` are compatible by construction, and the +freeze "drops `-draft` without a flag day". §13 U2 spells out the consequence for +frame kinds — a receiver encountering an unrecognised `kind` **MUST** treat the +frame as opaque evidence, **MUST NOT** crash, and a new kind is an ordinary `1.x` +addition. + +The reference implementation contradicted its own specification. `FrameKind` was +a closed `#[derive(Deserialize)]` enum over seven variants, which means: + +- **A `1.1` frame breaks a `1.0` host.** Not degrades — *fails*. `serde` rejects + an unknown variant, so the whole frame fails to deserialize, and in the NDJSON + binding that fails the envelope. The exact flag day §3.1 promises cannot happen. +- **Adding a kind breaks every downstream `match`.** Any exhaustive match in + consumer code stops compiling the day a variant is added, so even the + additive-only path was a breaking change for the ecosystem. + +The JSON Schema carried the same closed `enum`, and the TypeScript and Python +SDKs carried closed union/`Literal` types with the same effect at their type +layers. Only the Go SDK, which types `Kind` as `string`, was accidentally correct. + +A guarantee the reference implementation cannot honour is not a guarantee. + +## Decision + +`FrameKind` becomes an open vocabulary, following the shape `EgressScope` +already established in this codebase: a closed **base** vocabulary of seven +kinds, plus `Unknown(String)`, with hand-written `Serialize`/`Deserialize` over a +flat string. + +### `Unknown(String)`, not `#[serde(other)]` + +`#[serde(other)]` requires a unit variant and **discards the original value**. A +host relaying a frame it did not fully understand would silently rewrite +`"kind": "trajectory"` to something else on the way out. A forward-compatibility +mechanism that corrupts data in a relay is worse than the failure it replaces — +the closed enum at least failed loudly. + +Preserving the string means unknown kinds round-trip byte-identically, which +§13 U2 now states explicitly as a requirement. + +### `#[non_exhaustive]` + +The enum is marked `#[non_exhaustive]`, forcing downstream `match` expressions to +carry a wildcard arm. This is a one-time break taken now so that every *future* +kind addition is non-breaking for every consumer — which is precisely what the +additive-only promise of §15 requires but could not previously deliver. + +### `FrameKind` is no longer `Copy` + +Owning an unknown kind's string costs the `Copy` impl. `Copy` was an ergonomic +convenience; forward compatibility is a protocol guarantee. Where the two +conflict the guarantee wins. + +## Two versioning axes, and why only one broke + +This change is **wire-compatible** and **Rust-semver breaking**, and conflating +the two would misread it. + +On the wire nothing changed: the same seven strings serialize identically, and +the *only* behavioural difference is that a frame which previously failed to +parse now parses. That is strictly more compatible, so no protocol-family bump is +implied — `contextgraph/1` is intact, and GOVERNANCE.md's additive-only rule is +satisfied. + +In Rust, adding a variant, marking the enum `#[non_exhaustive]`, and dropping +`Copy` are all breaking, so the crates need a major version. `frame_kind_name` +in `contextgraph-host` correspondingly changes from +`fn(FrameKind) -> &'static str` to `fn(&FrameKind) -> &str` and becomes a +delegation to `FrameKind::as_str`, removing a second copy of the vocabulary. + +`docs/stability.md` already documents the crate-semver / protocol-version +distinction; this is its first substantive instance. + +## Alternatives considered + +**`kind: String` against a registry.** Rejected. It discards the type safety that +makes the seven common cases pleasant to work with, and pushes every consumer +into stringly-typed comparisons for no gain over `Unknown(String)`. + +**Leave it closed and treat new kinds as a major-family break.** Rejected. It +contradicts §3.1 and §13 U2, and makes the vocabulary effectively unextendable — +adding one frame kind would force `contextgraph/2` and a flag day for every +deployed implementation. + +**Open the JSON Schema's `enum` too.** Rejected. The schema is deliberately an +*authoring-strict lint* (§13), not the interop contract; the closed enum is what +catches a typo like `"dcc"` in a fixture. A `$comment` at that definition now +states the distinction explicitly, because it is exactly where a reader would +misread the schema as the wire rule. + +## Consequences + +- `contextgraph-types` and `contextgraph-host` require a major version bump. +- `SPEC.md` §13 U2 additionally requires verbatim preservation of an unrecognised + `kind` on re-emission. +- The TypeScript SDK exports `KnownFrameKind`, `KNOWN_FRAME_KINDS`, and + `isKnownFrameKind`, with `FrameKind = KnownFrameKind | (string & {})` so + autocomplete survives. The Python SDK exports `KnownFrameKind` and + `KNOWN_FRAME_KINDS`, with `FrameKind = str`. The Go SDK needed no change. +- `contextgraph-conformance`'s `frame_kind_from_wire` now delegates to + `FrameKind::from_wire` and filters on `is_known`, rather than restating the + seven names a third time. diff --git a/docs/index.md b/docs/index.md index ab8a87b..865e7d1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -57,10 +57,11 @@ Reference documentation for the **Context Graph Protocol (CGP)** crates: and how to get your own provider listed. - [**Stability**](./stability.md) — the crate-semver vs. protocol-version relationship, and what changes (and doesn't) as the protocol moves from - `contextgraph/1.0` to `contextgraph/1.0`. + `contextgraph/1.0` to a later `contextgraph/1.x`. Also at the repo root: [`GOVERNANCE.md`](../GOVERNANCE.md) (how the protocol is -maintained, what counts as a normative change, and the path to `contextgraph/1.0`), +maintained, what counts as a normative change, and the path to shared +stewardship), [`SECURITY.md`](../SECURITY.md) (vulnerability reporting), [`CODE_OF_CONDUCT.md`](../CODE_OF_CONDUCT.md), [`schema/`](../schema/) (machine-readable JSON Schema for the wire types), and diff --git a/docs/overview.md b/docs/overview.md index 7e8f2f3..0c68f07 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -161,7 +161,7 @@ prompt. MCP tools do the work. check on purpose, so you know the checks are real. - **Stability you can pin.** The protocol version is `contextgraph/1.0`. Two versions interoperate when they share a major family, the part before the first dot. So - `contextgraph/1.0` and `contextgraph/1.0` both belong to family `contextgraph/1` and interoperate. + `contextgraph/1.0` and `contextgraph/1.1` both belong to family `contextgraph/1` and interoperate. When the draft freezes, every deployed provider keeps working. No flag day. --- diff --git a/docs/protocol-advantages.md b/docs/protocol-advantages.md index 21dbac4..b36d349 100644 --- a/docs/protocol-advantages.md +++ b/docs/protocol-advantages.md @@ -290,7 +290,7 @@ version** (the wire-format identity negotiated at handshake). The current protocol version is `contextgraph/1.0`. Two protocol versions interoperate when they share a **major family** — the -substring up to the first `.`. So `contextgraph/1.0` and `contextgraph/1.0` both belong to +substring up to the first `.`. So `contextgraph/1.0` and `contextgraph/1.1` both belong to family `contextgraph/1` and interoperate. A jump to `contextgraph/2.0` does not. This design has a critical consequence: **the freeze from draft to stable does diff --git a/docs/protocol-surface.md b/docs/protocol-surface.md index d1d3ab1..ff25161 100644 --- a/docs/protocol-surface.md +++ b/docs/protocol-surface.md @@ -411,7 +411,7 @@ The **major family** is the substring up to (but not including) the first `.` — e.g. the family of `contextgraph/1.0` is `contextgraph/1`. Two version strings interoperate if and only if they share a major family. -`contextgraph/1.0` and `contextgraph/1.0` both belong to family `contextgraph/1` and interoperate; +`contextgraph/1.0` and `contextgraph/1.1` both belong to family `contextgraph/1` and interoperate; `contextgraph/2.0` does not interoperate with either. The `-draft` suffix marks a not-yet-frozen version within a family and does not affect interoperability. This rule is implemented by `contextgraph-host::wire::versions_compatible`; an diff --git a/docs/registry.md b/docs/registry.md index 4f18d6d..290c71b 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -7,11 +7,12 @@ conformant** — green on checkable report backing the claim. It exists so "conformant" stays a verifiable fact about a specific build, not a badge anyone can paste in. -Listings here are also load-bearing for governance: the freeze from -`contextgraph/1.0` to `contextgraph/1.0` requires **at least two -independent implementations** passing the suite -([GOVERNANCE.md](../GOVERNANCE.md#the-path-to-contextgraph10)). This registry -is where that count becomes checkable. +Listings here are also load-bearing for governance: the move from single- +maintainer stewardship to a group drawn from independent implementers is +triggered by **adoption** — independent implementations passing the suite — +not by a calendar date +([GOVERNANCE.md](../GOVERNANCE.md#governance-evolution)). This registry is +where that count becomes checkable. ## Conformant providers diff --git a/docs/stability.md b/docs/stability.md index 4fbceec..802b8fc 100644 --- a/docs/stability.md +++ b/docs/stability.md @@ -2,21 +2,31 @@ Context Graph Protocol (CGP) has two independent version axes: -- **Crate version:** `1.0.0`, inherited from the workspace `Cargo.toml` by the +- **Crate version:** `2.0.0`, inherited from the workspace `Cargo.toml` by the public Rust crates. This follows ordinary semantic versioning. - **Protocol version:** `contextgraph/1.0`, exposed as `contextgraph_types::PROTOCOL_VERSION` and negotiated on the wire. -A crate patch can improve implementation behavior without changing the wire -protocol. A protocol-breaking change requires a new protocol and crate major. +The axes move independently, and today they disagree: the crates are on `2.x` +while the wire contract is still `contextgraph/1.0`. A crate patch can improve +implementation behavior without changing the wire protocol; a crate major can +reshape the Rust API over an unchanged wire. The implication runs one way only — +a protocol-breaking change requires a new protocol **and** a crate major, but a +crate major does not imply a protocol break. ## The 1.0 stability guarantee The `contextgraph/1.0` wire contract is frozen. Within the `contextgraph/1` family, changes are additive: defined fields are not removed, renamed, or repurposed; receivers continue to follow the extensibility rules in SPEC §13. -Rust crates follow semver: `1.x` releases preserve public compatibility, while -a breaking redesign requires both `contextgraph/2.0` and crate version `2.0.0`. +Rust crates follow semver independently of that freeze: within a crate major, +releases preserve public compatibility; a breaking Rust API change takes a crate +major and nothing else. Crate `2.0.0` is the first exercise of this — the +open-`FrameKind` change ([ADR 0011](adr/0011-open-frame-kind-vocabulary.md)) is +Rust-breaking (`#[non_exhaustive]`, a new `Unknown(String)` variant, no longer +`Copy`) while emitting and accepting exactly the bytes `contextgraph/1.0` +already defined. A **wire**-breaking redesign is the stricter case: it requires +`contextgraph/2.0` and a crate major together. The former `contextgraph/1.0-draft` identifier belongs to the same major family and remains wire-compatible. This deliberate compatibility means existing @@ -40,8 +50,11 @@ and external implementations tied to the stable contract. ## Dependency guidance -Use a compatible stable requirement such as `contextgraph-types = "1"`, and -upgrade within `1.x` normally. Do not hardcode a protocol identifier: use +Use a compatible stable requirement such as `contextgraph-types = "2"`, and +upgrade within `2.x` normally. `1.x` remains on crates.io and still speaks +`contextgraph/1.0` on the wire, so a `1.x` consumer interoperates with a `2.x` +one; see [MIGRATION.md](../MIGRATION.md) §5 for the source changes the major +asks of you. Do not hardcode a protocol identifier: use `contextgraph_types::PROTOCOL_VERSION` and `contextgraph_host::wire::versions_compatible`, or implement the equivalent major-family comparison in another language. diff --git a/schema/contextgraph-envelope.schema.json b/schema/contextgraph-envelope.schema.json index 63f405c..2a4291b 100644 --- a/schema/contextgraph-envelope.schema.json +++ b/schema/contextgraph-envelope.schema.json @@ -477,7 +477,8 @@ "memory", "episode", "graph" - ] + ], + "$comment": "AUTHORING LINT, NOT THE INTEROP CONTRACT. These are the base kinds of contextgraph/1.0; the closed enum exists so a typo in a fixture ('dcc') fails CI. On the wire the vocabulary is OPEN: SPEC.md §13 U2 requires a receiver to accept an unrecognised kind (most likely one added by a later 1.x), treat the frame as opaque evidence, and preserve the original string verbatim if it re-emits the frame. Do NOT use this schema to validate messages from a peer — rejecting a frame here would break the family's no-flag-day guarantee. See contextgraph_types::FrameKind::Unknown." }, "Representation": { diff --git a/sdk/create-contextgraph-provider/index.js b/sdk/create-contextgraph-provider/index.js index 78f1a47..19d3126 100644 --- a/sdk/create-contextgraph-provider/index.js +++ b/sdk/create-contextgraph-provider/index.js @@ -28,10 +28,18 @@ import { fileURLToPath } from "node:url"; const HERE = dirname(fileURLToPath(import.meta.url)); -/** The published dependency each template resolves by default (see #59). */ +/** + * The published dependency each template resolves by default (see #59). + * + * Tracks the SDK majors, which move in lockstep with the crates: the + * open-`FrameKind` break (ADR 0011) widens `FrameKind` in both SDKs, so a + * scaffold pinned to a 1.x SDK would be generating code against the closed + * vocabulary this revision retired. The TypeScript entry had also fallen a + * major behind its own manifest, which shipped 1.0.0. + */ const DEFAULT_SDK = { - typescript: "^0.1.0", - python: "contextgraph-sdk>=1.0.0", + typescript: "^2.0.0", + python: "contextgraph-sdk>=2.0.0", }; function fail(message) { diff --git a/sdk/create-contextgraph-provider/package.json b/sdk/create-contextgraph-provider/package.json index ebfb1dc..3517e8d 100644 --- a/sdk/create-contextgraph-provider/package.json +++ b/sdk/create-contextgraph-provider/package.json @@ -1,6 +1,6 @@ { "name": "create-contextgraph-provider", - "version": "1.0.0", + "version": "2.0.0", "description": "Scaffold a conformant Context Graph Protocol provider (TypeScript or Python) with contextgraph-inspect running in its CI from day one.", "license": "MIT OR Apache-2.0", "type": "module", diff --git a/sdk/python/contextgraph_sdk/types.py b/sdk/python/contextgraph_sdk/types.py index 3b2663b..685e618 100644 --- a/sdk/python/contextgraph_sdk/types.py +++ b/sdk/python/contextgraph_sdk/types.py @@ -12,7 +12,31 @@ PROTOCOL_VERSION = "contextgraph/1.0" -FrameKind = Literal["snippet", "symbol", "fact", "doc", "memory", "episode", "graph"] +#: The base frame kinds of ``contextgraph/1.0``. +KnownFrameKind = Literal[ + "snippet", "symbol", "fact", "doc", "memory", "episode", "graph" +] + +#: Every base kind this revision names — a registry, not a restriction. +KNOWN_FRAME_KINDS: tuple[str, ...] = ( + "snippet", + "symbol", + "fact", + "doc", + "memory", + "episode", + "graph", +) + +#: What kind of thing a frame represents. +#: +#: The vocabulary is **open**: a later ``contextgraph/1.x`` may add kinds, and +#: SPEC.md §13 U2 requires a receiver to accept an unrecognised one, treat the +#: frame as opaque evidence, and preserve the original string verbatim if it +#: re-emits the frame. Typing this as a bare ``Literal`` would make a 1.1 frame +#: a type error on a 1.0 consumer — the flag day the version promise rules out. +#: Use :data:`KnownFrameKind` when you genuinely mean only the seven base kinds. +FrameKind = str Representation = Literal["full", "compact", "reference"] ContentFidelity = Literal["exact", "normalized", "summarized", "omitted"] VerdictStatus = Literal["valid", "stale", "gone", "unknown"] diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 0fc227e..6ff11bf 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "contextgraph-sdk" -version = "1.0.0" +version = "2.0.0" description = "Zero-dependency Python SDK for building conformant Context Graph Protocol providers." readme = "README.md" requires-python = ">=3.9" diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 822c2c9..a6f2354 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@contextgraphprotocol/typescript-sdk", - "version": "1.0.0", + "version": "2.0.0", "description": "Zero-dependency TypeScript SDK for building conformant Context Graph Protocol providers.", "license": "MIT OR Apache-2.0", "type": "module", diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts index 2ba855a..4fafa16 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -10,8 +10,8 @@ /** The protocol version this SDK speaks. */ export const PROTOCOL_VERSION = "contextgraph/1.0" as const; -/** What kind of thing a frame represents. */ -export type FrameKind = +/** The base frame kinds of `contextgraph/1.0`. */ +export type KnownFrameKind = | "snippet" | "symbol" | "fact" @@ -20,6 +20,37 @@ export type FrameKind = | "episode" | "graph"; +/** + * What kind of thing a frame represents. + * + * The vocabulary is **open**. A later `contextgraph/1.x` may add kinds, and + * SPEC.md §13 U2 requires a receiver to accept an unrecognised one, treat the + * frame as opaque evidence, and preserve the original string verbatim if it + * re-emits the frame. A closed union would make a 1.1 frame a type error on a + * 1.0 consumer — the flag day the version promise rules out. + * + * The `(string & {})` arm keeps editor autocomplete for the seven base kinds + * while accepting any string; narrow with {@link isKnownFrameKind} when you need + * to branch on a kind you understand. + */ +export type FrameKind = KnownFrameKind | (string & {}); + +/** Every base kind this revision names — a registry, not a restriction. */ +export const KNOWN_FRAME_KINDS: readonly KnownFrameKind[] = [ + "snippet", + "symbol", + "fact", + "doc", + "memory", + "episode", + "graph", +] as const; + +/** Whether `kind` is one of the seven base kinds this revision defines. */ +export function isKnownFrameKind(kind: string): kind is KnownFrameKind { + return (KNOWN_FRAME_KINDS as readonly string[]).includes(kind); +} + /** How a frame carries its content. Absent on the wire ⇒ `full`. */ export type Representation = "full" | "compact" | "reference";