feat(contextgraph-types): implement record_hash and RecordAttestation - #114
Merged
Conversation
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>
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 15 hours and 54 minutes by commenting @sourcery-ai review. Upgrade to get a review now.
Reviewer's GuideImplements lifecycle-profile record hashing and detached Ed25519 attestation as optional contextgraph-types features, with domain-separated verification, RFC 8785 conformance evidence, reproducible golden vectors, cross-language validation, updated protocol documentation, and CI coverage for each feature combination. Sequence diagram for record hashing and detached attestation verificationsequenceDiagram
participant Provider
participant RecordAttest
participant Canonicalizer
participant Verifier
participant Ed25519
Provider->>RecordAttest: record_hash(record)
RecordAttest->>Canonicalizer: JCS(record without top-level record_hash)
Canonicalizer-->>RecordAttest: canonical preimage
RecordAttest-->>Provider: sha256:<hex>
Provider->>RecordAttest: sign_record(record, signing_key_seed)
RecordAttest->>RecordAttest: record_attestation_message(record_hash)
RecordAttest->>Ed25519: sign(domain tag + raw digest)
Ed25519-->>Provider: detached RecordAttestation
Verifier->>RecordAttest: verify_record_attestation(record, attestation, public_key)
RecordAttest->>Canonicalizer: recompute JCS hash
Canonicalizer-->>RecordAttest: expected record hash
RecordAttest->>Ed25519: verify(domain tag + raw digest)
Ed25519-->>Verifier: AttestationVerdict
Flow diagram for omit-self record hashingflowchart TD
Input["Record JSON value"] --> Object{"Top-level object?"}
Object -- No --> Error["RecordHashError::NotAnObject"]
Object -- Yes --> Remove["Remove top-level record_hash"]
Remove --> Keep["Keep nested record_hash members"]
Keep --> JCS["RFC 8785 canonicalization"]
JCS --> SHA["SHA-256 canonical bytes"]
SHA --> Digest["sha256:<64 lowercase hex>"]
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
SCR-003 DoD check passed — every linked issue's definition of done is fully checked. |
This was referenced Aug 30, 2026
…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>
This was referenced Aug 30, 2026
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>
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>
Two doc conflicts, both in tables this branch and main each added a row to. docs/GUIDE.md — main's #109 added ADR 0013 plus a comment saying 0009, 0010 and 0011 were missing from the table and tracked in #129. This branch fills that gap, so the merged table carries 0009 through 0013 and 0017, and the comment goes with the gap it described (#129). docs/profiles/context-exchange-provider.md §9 — LF1 and LF3 are this branch's, describing the vectors it publishes and what the suite now checks; LF2 is main's, where #109 moved the schema $id from a GitHub-raw URL to the protocol's own branded, family-versioned one. Signed-off-by: macanderson <mac@oxagen.sh>
macanderson
added a commit
that referenced
this pull request
Aug 30, 2026
clippy 1.98's `chunks_exact_to_as_chunks` fires on `decode_hex`. The even-length check above the call already rules out a remainder, so `.0` discards nothing, and `&[u8; 2]` indexes without the bounds check a `&[u8]` carries. The sibling fix in `contextgraph-types::attest` landed with #114, so only this site remained. Refs #160
12 tasks
macanderson
added a commit
that referenced
this pull request
Aug 30, 2026
clippy 1.98's `chunks_exact_to_as_chunks` fires on `decode_hex`. The even-length check above the call already rules out a remainder, so `.0` discards nothing, and `&[u8; 2]` indexes without the bounds check a `&[u8]` carries. The sibling fix in `contextgraph-types::attest` landed with #114, so only this site remained. Refs #160
macanderson
added a commit
that referenced
this pull request
Aug 30, 2026
* feat(conformance): check provenance attestation adversarially (F6-F9) SPEC.md §6.5's F6-F9 shipped with their "Verified by" column pointing at `contextgraph_types::attest` — the implementation's own unit tests. Every other guarantee in this protocol earns its credibility from a suite with an adversarial mode behind it, and a guarantee whose only witness is the implementation asserting about itself is the self-attestation §11.1 exists to rule out. The new `attestation` check reads the wire like the §R1, §E1 and §H4 probes do: it takes the attester keys the handshake published, recomputes each served frame's commitment from the frame in hand, and verifies the signature over it in the order §6.5.4 fixes — commitment first, so "the frame moved after signing" is never reported as "the key is wrong". `contextgraph-example-docs` signs what it serves, and grows five `--misbehave` modes, one per forgery the constructions exist to stop: forge-signature wrong key -> BadSignature lift-signature A's signature stapled to B -> CommitmentMismatch truncate-chain a hidden `derivation` link -> CommitmentMismatch swap-content other bytes, signed frame id -> CommitmentMismatch malformed-attestation garbage -> MalformedCommitment `lift-signature` is the one a plausible implementation really does get wrong — sign the bare chain head and every frame citing the same source shares a valid signature. It serves two frames from one backing file so their chain heads and `content_digest`s are equal and only the frame id separates their commitments; `an_attestation_lift_differs_only_in_the_frame_id` asserts that precondition instead of trusting it, because a mode that fails for an unrelated reason proves nothing. Deleting the identity binding from `frame_commitment` makes the mode pass and turns `conformance-red.sh` red, which is the evidence that the check is worth having. `malformed-attestation` also holds F9: the frame stays served, degraded to unattested. The probe asks the reference host that question directly rather than trusting its own bookkeeping, because a host that dropped such frames would hand any peer a denial-of-service primitive. Attestations reach the verifier through two optional envelope members (§6.5.5): `handshake_ack.attester_keys` and `frames.attestations`, detached per F6. Both are additive within contextgraph/1 — a peer that knows nothing about them drops them. §6.5.2 now also pins `provider_id` to the handshake-declared `provider.name`, the only identifier both ends observe. Issue #90 owns the fuller wire treatment (result-set Merkle roots, inclusion proofs); this is the minimum the check cannot run without. A provider that publishes no key and serves no attestation passes: §6.5 makes the construction mandatory and the signing optional, and `conformance-external.sh` treats a skip as a failure. Closes #89 * fix(contextgraph-conformance): parse hex by const-sized chunk clippy 1.98's `chunks_exact_to_as_chunks` fires on `decode_hex`. The even-length check above the call already rules out a remainder, so `.0` discards nothing, and `&[u8; 2]` indexes without the bounds check a `&[u8]` carries. The sibling fix in `contextgraph-types::attest` landed with #114, so only this site remained. Refs #160 * fix(contextgraph-host): the wire FrameAttestation lives under wire::, the trust one keeps the root export
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pull request
Summary
Implements the lifecycle profile's
record_hash(RFC 8785 JCS + the omit-selfrule) and
RecordAttestationverification (detached Ed25519), and publishesgolden vectors so profile
LF1becomes a true claim. Closes #96.What changed
The library —
contextgraph_types::record_attest(new module, the record-layersibling of
attest):record_hash(&Value)/record_hash_preimage(&Value)/record_hash_of(&ContextRecord)/
record_hash_is_current(&Value). The preimage is exposed as well as the digestbecause a digest cannot tell an implementer where their canonicalization diverged.
sign_record,sign_record_attestation,verify_record_attestation,verify_signed_record_hash,record_attestation_message. Verdicts reuse theframe layer's
AttestationVerdict— a shared result vocabulary, while the twoattestation types stay distinct as ADR 0010 argued.
record-hash(serde_json,serde_json_canonicalizer) andrecord-attestation(record-hash+attestation). The default dependency set is still serde only.Decisions (recorded in ADR 0017):
LH1says removed, and the ADR recordswhat that buys: a record hashes identically whether the member is absent,
correct, or wrong, so a producer never invents a placeholder and a verifier
never has to know which one was chosen. Only the top-level member is removed —
a
record_hashinsideextensionsis content and stays in the preimage.serde_json_canonicalizer0.3.2 (MIT,~6M downloads, last published 2026-02), whose number formatting goes through
ryu-js(Apache-2.0 OR BSL-1.0), the Boa engine's ECMAScript formatter. It wasalready a dev-dependency of the conformance suite; this promotes it to a pinned
workspace dependency so the library and the suite checking it cannot
canonicalize with two different versions. Both licenses are compatible with the
workspace's MIT OR Apache-2.0.
LC4): the signed messageis
"contextgraph/attest/1/record"followed by the digest's 32 raw bytes. Aframe commitment is domain-bound by construction; a
record_hashis a plainSHA-256 over a JSON document that anyone else can also compute, so signing it
raw would make one signature mean whatever the presenter says it means. Additive
—
LC3named no preimage because nothing had implemented signing.normative
LC5), so the obvious laundering move — edit the record, then rewriterecord_hashso it is internally consistent again — is aCommitmentMismatch.Vectors:
tests/fixtures/record-hash-vectors.json— the canonical JCS text of everyrecord fixture beside its hash.
tests/fixtures/record-attestation.json— a real Ed25519 signature. Itpreviously carried 49 bytes of DER-shaped filler, in the directory
LF1callsthe canonical home for the profile's golden vectors.
tests/fixtures/record-attestation-key.json— the published test key (seed,public key, signed message), labelled as forgeable everywhere it appears.
contextgraph-types/tests/record_vectors.rs— the same values inline, so theytravel inside the published crate.
Checking:
than a private copy of the rule, and verifies the attestation under its key.
schema/validate-examples.pychecks, in Python and with no JCS library, thateach published canonical text hashes to its published digest and parses back to
the fixture with
record_hashremoved. It deliberately does not claimPython canonicalizes like RFC 8785; the profile prose that implied it did is
corrected.
featuresCI job builds and runs each feature combination. Every featurethis crate has is off by default and no job turned any of them on, so the
attestation code from Sign the provenance chain, open the FrameKind vocabulary #87 compiled nowhere in CI.
Corrections found on the way:
LH2said JCS sorts object members by codepoint. RFC 8785 §3.2.3 sorts by UTF-16 code unit, and the two orders differ
for a supplementary character (its lead surrogate sorts below U+E000) — the new
test demonstrates it. The profile and fixtures README also claimed a Python
json.dumpscanonicalizer reproduces the vectors byte-for-byte; that holds forthese fixtures because their keys are ASCII and their numbers round-trip
identically, and the prose now says so instead of overclaiming.
Evidence
RFC 8785 conformance against the RFC's own published vectors (from
https://www.rfc-editor.org/rfc/rfc8785.txt, cited in the tests):asserted as bytes.
text —
-0→0,5e-324,1.7976931348623157e+308, the1e+21and0.000001exponent thresholds, the round-to-even sample.The twelve fixtures'
record_hashvalues are unchanged by this PR. That isthe evidence this implements the existing rule rather than redefining it: the
library reproduces byte-for-byte what the suite's private helper computed.
Witness — fails without the change, passes with it. Two observations, both
run in this worktree:
The attestation vector. With
origin/main'srecord-attestation.jsonrestored into the new tree:
Restoring the new fixture:
test result: ok. 1 passed.The library API.
git stash pushof the tracked source changes (bothCargo.tomls,src/lib.rs,src/record.rs), leaving the new tests in place:After
git stash pop:test result: ok. 8 passed(conformance) andtest result: ok. 5 passed(record_vectors).One honest caveat:
record_vectors.rscarries a file-level#![cfg(feature = "record-attestation")], matchingattestation_vectors.rs,so on the old tree it compiles to zero tests and reports green rather than
failing. That vacuous-green shape is exactly what the new
featuresCI jobdefends against, by naming each feature explicitly.
Other commands run (all in the worktree, scoped per SCR-001):
cargo test -p contextgraph-types --all-features— 153 + 5 + 2 + 5 + 4 passed.cargo test -p contextgraph-conformance --test lifecycle_profile_examples— 8 passed.cargo test -p contextgraph-conformance --test golden_fixtures— 10 passed.cargo clippy -p contextgraph-typeswith each of(none),attestation,record-hash,record-attestation,--all-features,--all-targets -D warnings— clean.cargo clippy -p contextgraph-conformance --all-targets -- -D warnings— clean.cargo fmt -- --check— clean.python3 schema/validate-examples.py— OK, all examples validate.python3 .github/scripts/check-deploy-hygiene.py— OK.cargo metadata --locked— lock in sync.Follow-up pushes
unused_importson a default build.use crate::record::RecordAttestation;sat at file scope but is named only by the signing and verifying code, so every
CI job except the new feature matrix failed
-D warnings. Gated torecord-attestation; the one ungated doc link naming the type is now anexplicit path so it still resolves with the import absent.
Worth recording how it got past me: my local check was
rg -c '^(error|warning)'over cargo's output. Cargo colourises when it thinksit is talking to a terminal, so the escape sits before the word and
^errormatches nothing — a filter that could not see the errors, reported as silence.
The same loop also mis-quoted
--features Xas a single argument, so three ofthe five combinations never ran. Everything above was re-verified by exit code.
ADR renumbered 0012 → 0017. PR ci(sdk): typecheck both typed SDKs, and catch a pin that drifted from its manifest #106 (since merged) adds
docs/adr/0012-sdk-version-pins-share-a-major.md. The two filenames differ, sogit merges both cleanly and the tree ends up holding two ADR 0012s with nothing
to catch it. Numbers are allocated centrally now; 0017 is this PR's.
docs/GUIDE.md's decision log completed. It stopped at 0008 — 0009, 0010,0011 and 0012 had each landed without a row. Adding only 0017 would have left a
table jumping 0008 → 0017 and still missing four decisions.
clippy::chunks_exact_to_as_chunks. Rust 1.98's clippy adds thiswarn-by-default lint, so
-D warningsreddened every fixed-width hex decoderhere — including
attest.rs'sfrom_hex, which predates this branch and isgreen on nothing today. CI pins
dtolnay/rust-toolchain@stable, so thetoolchain moved under a tree nobody had changed; the pre-existing site is fixed
here because the job cannot go green while it stands. The floating pin itself
is CI floats on stable Rust, so a new clippy release turns main red with no commit #160.
This one got past a local run because cargo replays a cached clippy result for
an unchanged crate, so a check that had passed before the lint existed kept
reporting success. Verification now touches every source first.
Merged
origin/main(ci(sdk): typecheck both typed SDKs, and catch a pin that drifted from its manifest #106, feat(contextgraph-host): cross-provider ranking is a seam, not raw score #113, feat(sdk): port provenance attestation to the TypeScript, Python, and Go SDKs #131, feat(schema): name the schemas' identity on a branded, family-versioned URL #109). Two doc conflicts, both intables this branch and main each added a row to:
docs/GUIDE.md's decision lognow carries 0009 through 0013 and 0017 — feat(schema): name the schemas' identity on a branded, family-versioned URL #109 had added 0013 with a comment
noting the 0009–0011 gap and tracking it in The GUIDE's ADR decision log stops at 0008 — three ADRs are missing and nothing notices #129, and this branch fills it —
and the profile's §9 takes LF1 and LF3 from here with LF2 from main, where feat(schema): name the schemas' identity on a branded, family-versioned URL #109
moved the schema
$idto the branded URL.Sourcery
Sourcery posted a review guide and an assessment table against #96 on the first
head (85fe829): all three objectives
✅, no❌, and no inline comments. It hasnot re-reviewed the two follow-up commits, which are the import gate, the ADR
rename and the index rows.
Residue filed
can protect nothing
ContextRecorddrops unknown members, so hashing a typed record candisagree with hashing its wire bytes
record_hashandRecordAttestationto the TypeScript, Python andGo SDKs
contextgraph-inspectcannot check a record hash or an attestationChecklist
fmt,clippy -D warnings,test(scoped to thetouched crates per SCR-001; the workspace run is CI's job)
README.md,docs/, doc comments,--helptext)git commit -s, DCO)CHANGELOG.mdupdated under[Unreleased]if user-visibleRegistry submission (only if adding a row to
docs/registry.md)Protocol-stability impact (if a spec/wire change)
contextgraph/1LC4andLC5are new normative rows in the lifecycle profile, which is adraft (
contextgraph/lifecycle/1.0-draft) and separate from the frozencontextgraph/1.0core. They pin a preimage that had no stated definition andthat nothing had implemented, so no deployed provider can be signing something
else today. The record wire shape, the record schema, and every fixture's
record_hashare unchanged.LH2's correction from "code point" to "UTF-16code unit" changes no fixture's bytes; it makes the prose match what RFC 8785
requires and what the reference implementation already did.
No new deleted tests.
License
By submitting this pull request, I agree to dual-license this contribution
under MIT OR Apache-2.0, as certified by my DCO sign-off.
Closes #96