Sign the provenance chain, open the FrameKind vocabulary - #87
Conversation
Three changes the protocol's own guarantees already implied but nothing enforced, plus the doc corrections found alongside them. **Provenance attestation (SPEC.md §6.5, F6-F9; ADR 0010).** 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 fabricates its digest too and every §6.2 check passes. `contextgraph_types::attest` adds a source-first hash chain over provenance links, a frame commitment binding that head to the full (provider_id, frame_id, content_digest) identity - without which a signature can be lifted off one frame and stapled onto a fabricated one - and an RFC 6962 Merkle root with inclusion proofs for selective disclosure. Verification is offline and returns a named verdict, never a boolean. Cryptography sits behind an off-by-default `attestation` feature so the crate keeps its zero-deps-beyond-serde promise; ProvenanceAttestation itself always compiles, because a host must be able to relay what it cannot check. attestation_vectors.rs publishes the byte vectors other languages reconcile against - a normative encoding without published vectors is a rule two implementations can both believe they follow. **FrameKind is now an open vocabulary (ADR 0011).** Rust-semver breaking, wire-compatible. The closed enum contradicted §3.1's no-flag-day promise and §13 U2: a kind added in a later 1.x did not degrade on a 1.0 host, it failed to deserialize. Unknown(String) preserves the original string so a relaying host re-emits it byte-identically - which #[serde(other)] cannot do - and #[non_exhaustive] makes every future addition non-breaking. **score semantics (SPEC.md §6.6, F10).** F1 gave score a range, not a scale, and the host ranked across providers as if it were commensurable. Now stated as provider-local and ordinal. Calibration was rejected as unenforceable: budget honesty is checkable because cost is a function of bytes both sides observe, and relevance has no such anchor. **GOVERNANCE.md** states the consent boundary - the host enforces local consent, not organizational policy - before it is contested. Fixed: the version-compat example gave `contextgraph/1.0` and `contextgraph/1.0` as two interoperating versions, in seven files; stale pre-freeze language and a dead GOVERNANCE anchor; README's "canonical architecture" and "draft v0.1.0" title.
|
SCR-003 DoD check passed — every linked issue's definition of done is fully checked. |
Reviewer's GuideThis PR adds an optional, wire-specified provenance evidence system with offline Ed25519 verification and Merkle inclusion proofs; makes FrameKind forward-compatible across minor protocol versions; clarifies provider-local score handling and the local-consent boundary; and updates ADRs, SDKs, schema, changelog, and compatibility documentation. Review the Rust-semver major-version decision, cross-language attestation vectors and cryptographic verification behavior, and note that TypeScript typechecking and host/conformance attestation integration remain follow-up or CI responsibilities. Sequence diagram for offline provenance attestation verificationsequenceDiagram
participant Provider
participant Host
participant Auditor
Provider->>Provider: provenance_chain_head(links)
Provider->>Provider: frame_commitment(provider_id, frame)
Provider->>Provider: sign_commitment(commitment, signing_key_seed)
Provider-->>Host: ContextFrame + ProvenanceAttestation
Host->>Host: verify_frame_attestation(provider_id, frame, attestation, public_key)
Host-->>Auditor: AttestationVerdict
Auditor->>Host: root_from_proof(commitment, proof)
Host-->>Auditor: Merkle root match or mismatch
State diagram for forward-compatible FrameKind handlingstateDiagram-v2
[*] --> WireKind
WireKind --> KnownKind: known base string
WireKind --> UnknownKind: unrecognized string
KnownKind --> Reemit: serialize
UnknownKind --> Reemit: serialize original string
Reemit --> [*]
Flow diagram for provenance evidence constructionflowchart LR
Links["Ordered provenance links"] --> Chain[provenance_chain_head]
Chain --> Commitment["frame_commitment(provider_id, frame)"]
Commitment --> Merkle["merkle_root(commitments)"]
Commitment --> Sign[sign_commitment]
Merkle --> Sign
Sign --> Attestation["Detached ProvenanceAttestation"]
File-Level Changes
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
The FrameKind vocabulary opens in this PR (ADR 0011): a new Unknown(String) variant, #[non_exhaustive], no longer Copy, and frame_kind_name becomes fn(&FrameKind) -> &str. That is breaking in Rust and invisible on the wire, so the crates take a major and the protocol version stays contextgraph/1.0 — the first case where the two axes in docs/stability.md actually disagree, and the reason they are documented as independent. All nine workspace crates inherit the bump from workspace.package. The internal contextgraph-* floors move from ">=1.0.0" to ">=2.0.0": host 2.0.0 does not compile against types 1.x, so the old floor would have let cargo resolve a pair that cannot build. The Python and TypeScript SDK manifests move in lockstep — FrameKind widens in both type systems, so an exhaustive switch relying on never-narrowing stops type-checking — as does create-contextgraph-provider, whose TypeScript default pin had additionally fallen a major behind its own manifest at ^0.1.0. docs/stability.md said a breaking redesign required "both contextgraph/2.0 and crate version 2.0.0", which forbade this bump two lines after declaring the axes independent. It now states the implication in the one direction that holds: a wire break needs a crate major, but a crate major does not need a wire break. MIGRATION.md §5 gives the three call-site shapes to fix, each a compile error rather than a silent runtime change. Verified: cargo check/test/clippy -D warnings clean on types + host (136 host, 132 types under --features attestation, 5 reference vectors); conformance, trace, mcp-bridge and mcp-server all resolve and check at the new floors; cargo fmt --check clean; Cargo.lock in sync. Closes #92
There was a problem hiding this comment.
Sorry @macanderson, you've used your own review budget of 250,000 diff characters for the last 7 days.
You can request another review in 2 days and 16 hours by commenting @sourcery-ai review. Upgrade to get a review now.
… its manifest (#106) * ci(sdk): typecheck both typed SDKs, and catch a pin that drifted from its manifest Two things the SDK directory shipped on a claim rather than a run. The TypeScript type changes in PR #87 were never typechecked — no local toolchain, and `npx tsc` failed in that environment. `sdk (typescript) is a conformant implementation` does run `tsc` as a side effect of `npm run build`, so the coverage was not zero, but it arrives only after a full `cargo build --workspace --bins` and lasts only as long as the build script stays `tsc`. `sdk (typescript) typechecks` asks the question directly. The Python SDK ships `py.typed`, which promises downstream typecheckers that its annotations are meant to be believed, and nothing checked them; `sdk (python) typechecks` runs `mypy --strict`. Both are clean on this tree. The scaffolder's `DEFAULT_SDK` table names versions of two packages it does not own, and it sat at `^0.1.0` against a shipped `1.0.0` for an unknown length of time. The scaffold job could not have caught it: it overrides both published pins with local paths so CI never depends on a publish, which is the right call for that job. `check-sdk-version-pins.py` is the guard that belongs elsewhere — offline, stdlib only, no registry call. The rule is same major, not equality, because the pins are deliberately ranges: a patch release should not need an edit here, and a major is what a scaffold cannot survive (ADR 0011 widened `FrameKind` in 2.0.0, so a `1.x` pin generates code against a retired vocabulary). ADR 0012 carries the reasoning, including why `sdk/go` and `schema/reference-vectors.ndjson` are out of scope. Closes #94, Closes #98 Signed-off-by: macanderson <mac@oxagen.sh> * ci(sdk): resolve the typecheck job's compiler from the lockfile `npm ci` rather than `npm install`, so the job runs typescript 5.9.3 as `package-lock.json` pins it instead of whatever `^5.9.0` resolves to today. The mypy job beside it is pinned for the same reason and this one was not, which Sourcery caught against #94's definition of done. The conformance jobs keep `npm install`: they ask whether the SDK still behaves, not whether one compiler release still accepts it. Refs #94 Signed-off-by: macanderson <mac@oxagen.sh> --------- Signed-off-by: macanderson <mac@oxagen.sh>
…#114) * feat(contextgraph-types): implement record_hash and RecordAttestation The lifecycle profile has always defined `record_hash` as the sha256 over the RFC 8785 (JCS) canonicalization of a record with its own `record_hash` removed (LH1), and `RecordAttestation` as a detached Ed25519 signature over it (LC3). Both were prose and a struct. The only hashing code in the workspace was a private helper inside the conformance suite, so the suite proved the fixtures agreed with the suite; and the attestation fixture carried 49 bytes of DER-shaped filler where a signature belongs, with no key published, so no implementation could reproduce or refute it. `contextgraph_types::record_attest` makes the rule callable, behind two new off-by-default features. `record-hash` adds RFC 8785 canonicalization (delegated to serde_json_canonicalizer, whose numbers route through ryu-js — JCS number serialization is ECMAScript Number::toString, and its exponent thresholds are where reimplementations diverge in silence). `record-attestation` adds Ed25519 on top. A frame-only consumer pays for neither, and the crate's zero-dependency default is unchanged. The signed message is domain-separated: "contextgraph/attest/1/record" followed by the digest's 32 raw bytes. A frame commitment is domain-bound by construction; a record_hash is a plain SHA-256 over a JSON document that any number of unrelated systems also compute, so signing it raw would let one signature mean whatever the presenter says it means. Verification recomputes the record's hash rather than reading the stored member, so editing a record and rewriting its hash to match a stolen signature is caught as a mismatch instead of passing. Evidence: the canonicalizer is checked against RFC 8785's own vectors — §3.2.4's byte listing, §3.2.3's sorting data, and Appendix B's IEEE 754 number table. The twelve fixtures' hashes are unchanged, which is what shows this reproduces the existing rule rather than redefining it. tests/fixtures/ now publishes the canonical preimage text of every fixture, a real signature, and the test key that produced it; the conformance suite recomputes all of it through the library. schema/validate-examples.py checks the vectors from Python, without a JCS library, so an implementer who has neither Rust nor a canonicalizer can still rely on them. A CI job builds and runs each feature combination — until now every feature this crate has was off by default and no job turned any of them on, so the attestation code added in #87 compiled nowhere in CI. Also corrects LH2, which said JCS sorts members by code point. RFC 8785 §3.2.3 sorts by UTF-16 code unit, and the two orders differ for a supplementary character. Closes #96 Signed-off-by: macanderson <mac@oxagen.sh> * fix(contextgraph-types): gate the RecordAttestation import, renumber the ADR to 0017 The import is used only by the signing and verifying code, so a default build — every CI job except the new feature matrix — failed `-D warnings` on `unused_imports`. Gated to `record-attestation`, with the one ungated doc link that named the type rewritten as an explicit path so it still resolves with the import absent. The local check that reported this clean was `rg -c '^(error|warning)'` over cargo's output. Cargo colourises when it thinks it is talking to a terminal, so the escape sits before the word and `^error` matches nothing — a filter that cannot see the errors, reported as silence. The loop also mis-quoted `--features X` as one argument, so three of the five combinations never ran at all. Re-verified by exit code. ADR 0012 renumbered to 0017: PR #106 adds a differently-named docs/adr/0012-*.md, and two files with different names merge cleanly into a tree holding two ADR 0012s with nothing to catch it. Numbers are now allocated centrally. docs/GUIDE.md's decision log gains the entry, along with 0009, 0010 and 0011, which had been missing since they landed — adding a row to an index while leaving it knowingly incomplete is not a fix. Refs #96 Signed-off-by: macanderson <mac@oxagen.sh> * docs(guide): complete the ADR decision log The index stopped at 0008; 0009, 0010, 0011 and 0012 had each landed without a row. Adding only 0017 would have left a table that jumps from 0008 to 0017 and still misses four decisions, so all five are in. Refs #96 Signed-off-by: macanderson <mac@oxagen.sh> * fix(contextgraph-types): use as_chunks for the fixed-width hex pairs Rust 1.98's clippy adds `chunks_exact_to_as_chunks`, warn-by-default, so `-D warnings` turned red on every hex decoder here — including `attest.rs`'s `from_hex`, which predates this branch. CI pins `dtolnay/rust-toolchain@stable`, so the toolchain moved under a tree nobody had changed; the pre-existing site is fixed here because the job cannot go green while it stands. `as_chunks::<2>()` is also the better shape: the length check above each loop already rules out a remainder, and a fixed-size chunk lets the compiler see both indexes are in bounds. The two copies of the hex decode in the conformance suite collapse into one `hex32` helper that also checks the length it assumes. A note on how the earlier local run missed this: cargo replays a cached clippy result for an unchanged crate, so a `clippy` that had passed before the lint existed kept reporting success. /tmp/verify.sh now touches every source first. Refs #96 Signed-off-by: macanderson <mac@oxagen.sh> --------- Signed-off-by: macanderson <mac@oxagen.sh>
Closes #92 — its Definition of done checklist is fully ticked and verified.
Addresses a review of the protocol surface. Four of the seven technical items raised turned out to be already handled — tokenizer identity (ADR 0003 defines a byte-based accounting unit, deliberately not a tokenizer), bitemporal time (
valid_from/valid_to/recorded_atall present), egress granularity (EgressScope's four-class vocabulary with per-scope consent), andGOVERNANCE.mditself. This PR is the remainder.1. Provenance attestation —
SPEC.md§6.5 (F6–F9), ADR 0010F5 makes provenance tamper-evident. It does not make it evidence. A digest proves the bytes have not changed since someone wrote that number down; the digest and the frame it describes come from the same unauthenticated party, so a provider willing to fabricate a frame fabricates its digest too and every §6.2 check passes.
RecordAttestationalready declared the right shape at the record layer — and the workspace carried no cryptographic dependency at all, so nothing implemented or verified it.contextgraph_types::attestadds:(provider_id, frame_id, content_digest)triple. Without the identity binding the scheme is a forgery primitive: two frames citing the same source share a chain head, so a signature over the head alone lifts off one frame and staples onto a fabricated one.Design notes: the encoding is length-prefixed rather than JCS (a provenance link is six optional strings; JCS would add a dependency on a conforming JSON canonicalizer for no gain, and length prefixes make the encoding injective — without them
uri:"ab",range:"c"collides withuri:"a",range:"bc"). Cryptography is behind an off-by-defaultattestationfeature, socontextgraph-typesstill resolves to serde alone by default.frame_commitmentis public so HSM/KMS holders sign the bytes themselves — the protocol specifies the preimage, not key custody.contextgraph-types/tests/attestation_vectors.rspublishes byte vectors for cross-language reconciliation; a diff there is a wire-breaking change.2.
FrameKindis an open vocabulary — ADR 0011Rust-semver breaking, wire-compatible. The closed enum contradicted the spec's own §13 U2, which already required a receiver to accept an unrecognised kind and not crash. It did worse than not degrade — a
1.1frame failed to deserialize on a1.0host, and in the NDJSON binding that fails the envelope.Unknown(String)follows theEgressScopeprecedent and preserves the original string, so a relaying host re-emits it byte-identically.#[serde(other)]cannot do this — it discards the value, so a1.0host would silently rewrite a1.1frame it was merely passing through.#[non_exhaustive]makes every future kind addition non-breaking. The cost isCopy.On the wire the only behavioural change is that a frame which previously failed to parse now parses — strictly more compatible, so
contextgraph/1is intact.TS/Python SDKs gain
KnownFrameKind+KNOWN_FRAME_KINDS; Go already typedKindasstring. The JSON Schema keeps its closedenum(it is a documented authoring lint, not the interop contract) with a$commentsaying so at the one place a reader would misread it.3.
scoresemantics —SPEC.md§6.6 (F10)F1 gave
scorea range, not a scale. Now stated as provider-local and ordinal. 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; a provider could satisfy any calibration rule we wrote while its numbers stayed meaningless.F10 forbids cross-provider thresholds and presenting raw scores as cross-provider relevance. It does not forbid ordering —
order_by_valuegenuinely needs a total order — but requires the host to own it as policy.fold_to_edgesis now public so a host with its own reranker gets the placement without the ranking.4.
GOVERNANCE.md— the consent boundaryFleet RBAC, central policy distribution, and aggregated cross-machine audit are out of scope. Written down now because the pressure runs one way: every individual request to let the host "just" read a policy file is reasonable, and collectively they make "conformant" mean "connected to someone's control plane."
5. Doc fixes
contextgraph/1.0andcontextgraph/1.0as two interoperating versions — in seven files, the residue of a global rename.docs/registry.md/docs/index.mddescribing a freeze that already happened, plus a deadGOVERNANCE.md#the-path-to-contextgraph10anchor.1.0.0.Verification
cargo clippy --workspace --all-targets -- -D warningsclean;cargo fmt --checkclean.contextgraph-types132 unit + 5 reference-vector tests,contextgraph-host136,contextgraph-conformancefull suite including the ~150s integration run — zero failures. Dependency tree confirmed: serde only by default,+ sha2 + ed25519-dalekunder the feature.Not verified here: the TypeScript SDK was not typechecked (no local toolchain;
npx tscfailed). CI should cover it.5. The crate major-version bump (was the draft blocker)
This PR was held in draft pending the crate major-version decision — #92. That is now applied, so it leaves draft.
The crates go to
2.0.0while the protocol version stayscontextgraph/1.0: the FrameKind change is breaking in Rust and invisible on the wire, which is the first case wheredocs/stability.md's two axes actually disagree. All nine workspace members inherit the bump; the internalcontextgraph-*floors move>=1.0.0→>=2.0.0, becausecontextgraph-host2.0.0 does not compile againstcontextgraph-types1.x and the old floor admitted a resolvable-but-unbuildable pair. The Python and TypeScript SDK manifests move in lockstep —FrameKindwidens in both type systems, so an exhaustiveswitchrelying onnever-narrowing stops type-checking — as doescreate-contextgraph-provider, whose TypeScript default pin had additionally fallen a major behind its own manifest at^0.1.0.docs/stability.mdneeded correcting to permit its own bump: it said a breaking redesign required "bothcontextgraph/2.0and crate version2.0.0" — two lines after declaring the axes independent. It now states the implication in the direction that holds.MIGRATION.md§5 gives the three call-site shapes to fix; each is a compile error, so the compiler enumerates the work and nothing changes silently at runtime.Re-verified after the bump:
cargo check/test/clippy -D warningsclean oncontextgraph-types+contextgraph-host(136 host, 132 types under--features attestation, 5 reference vectors, 0 failures);contextgraph-conformance,contextgraph-trace,contextgraph-mcp-bridge,contextgraph-mcp-serverall check clean at the new floors;cargo fmt --checkclean;Cargo.lockin sync.Summary by Sourcery
Strengthen provenance evidence and forward compatibility while separating the Rust crate major version from the unchanged contextgraph/1.0 wire protocol.
New Features:
Bug Fixes:
Enhancements:
Build:
Documentation:
Tests: