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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,13 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo test --workspace
# `attestation` is off by default and no workspace member enables
# it, so the line above compiles neither contextgraph_types::attest
# nor the cross-language vectors the SDK suites reconcile against.
# `attestation` is off by default, so the line above reaches
# contextgraph_types::attest and the cross-language vectors the SDK
# suites reconcile against only through feature unification with
# contextgraph-conformance's dev-dependency. That is a resolver detail,
# and a guarantee resting on one is not a guarantee: ask for the feature
# by name, so those constructions are checked whatever the resolver
# decides and whatever any other crate happens to request.
- run: cargo test -p contextgraph-types --features attestation

features:
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,33 @@ text lands without a human merge.
hexadecimal byte listing, §3.2.3's property-sorting data, and Appendix B's
table of IEEE 754 bit patterns and their required ECMAScript text, including
the `-0` case and the `1e+21` / `0.000001` exponent thresholds.
- **Attestations travel on the wire (`SPEC.md` §6.5.5, F11–F13;
[ADR 0014](./docs/adr/0014-attestations-on-the-wire.md)).**
`ProvenanceAttestation` existed with no envelope to carry it, so a provider
that signed a frame had nowhere to put the signature and the feature was
reachable only by out-of-band agreement. A `frames` result now carries
`frame_attestations` — one entry per attested frame, naming the
`(provider_id, frame_id, content_digest)` identity it covers in full rather
than by array position — and `result_attestation`, one signature over the
Merkle root of exactly the frames returned. They sit on the *result* rather
than the envelope because an attestation is a property of the answer, like
`truncated`, and an in-process provider that never builds an envelope must
still be able to sign what it serves. Both are optional and omitted when
empty, so an unsigned answer serializes to the same bytes as before and a
1.0 peer that ignores them still parses a signed one. **Inclusion proofs are
optional on the wire**, and a host that keeps only part of a signed answer
derives and retains them before dropping the rest — once the siblings are
gone the root can never be recomputed, and selective disclosure is destroyed
by ordinary composition.
- **Every attestation this repository ships is verified, not asserted.**
`examples/` gains a signed exchange, `schema/reference-vectors.ndjson` gains a
`frames/attested` vector, and
`contextgraph-conformance/tests/attestation_wire.rs` recomputes every
commitment, rebuilds every root, replays every inclusion proof, and checks
every signature against the published example key. A wire example whose
signature nobody checks teaches an implementer to produce forgeries. As a side
effect the §6.5 constructions now compile and run under `cargo test`, which
nothing in CI did before.
- **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
Expand Down
93 changes: 93 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,9 @@ loud.
| **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 |
| **F11** | An attestation **MUST** travel beside the frames it covers, in the result's `frame_attestations` / `result_attestation` members, and **MUST NOT** appear as a member of a `ContextFrame` (F6 on the wire). A `frame_attestations` entry **MUST** name the full *(provider id, frame id, `content_digest`)* identity it attests rather than implying it by array position, and **MUST** name a frame the same result carries. | `attestation_wire` suite; envelope schema |
| **F12** | A `result_attestation`'s `signed_commitment` **MUST** be the §6.5.3 Merkle root over the commitments of **exactly** the frames carried in `result.frames`, in canonical order — never over a larger candidate set the provider truncated away. | `attestation_wire` suite; `contextgraph_types::attest::result_set_root` |
| **F13** | An `inclusion_proof` is **OPTIONAL**, and when present **MUST** recompute the `result_attestation` root. A host that retains a strict subset of a signed result set **MUST** derive and retain the proofs for the frames it keeps *before* dropping the rest; once the siblings are gone the root can never be recomputed. | `attestation_wire` suite; host composition |

### 6.1 Temporal profile (F4)

Expand Down Expand Up @@ -527,6 +530,96 @@ 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.5.5 Carrying an attestation on the wire (F11–F13)

A `frames` envelope's `result` carries two optional members. Both are omitted
when empty, so an unsigned answer is byte-identical to one from a provider
written before attestation existed, and a 1.0 peer that ignores them still reads
a signed answer as a valid answer (§13 U1).

```jsonc
{
"type": "frames",
"id": "q3",
"result": {
"frames": [
// the answer's frames, elided — see examples/reference-messages.json
],
"truncated": false,

// One entry per attested frame, naming the identity it covers in full.
"frame_attestations": [
{
"frame": { "provider_id": "repo-graph", "frame_id": "repo-graph:retry-doc",
"content_digest": "sha256:<64 hex>" },
"attestation": {
"signed_commitment": "sha256:<64 hex>", // the §6.5.2 frame commitment
"key_id": "repo-graph-2026-08",
"algorithm": "ed25519",
"attester_id": "repo-graph",
"signature": "<128 hex>",
"issued_at": "2026-08-29T12:00:00Z"
},
"inclusion_proof": {
"leaf_index": 0,
"leaf_count": 2,
"path": [{ "sibling": "sha256:<64 hex>", "sibling_is_left": false }]
}
}
],

// One signature over the whole answer: the §6.5.3 Merkle root.
"result_attestation": {
"signed_commitment": "sha256:<64 hex>",
"key_id": "repo-graph-2026-08",
"algorithm": "ed25519",
"attester_id": "repo-graph",
"signature": "<128 hex>",
"issued_at": "2026-08-29T12:00:00Z"
}
}
}
```

They sit on the **result** rather than on the envelope for the same reason
`truncated` does: an attestation is a property of the answer, not of the
transport. The envelope carries only `type` and the correlation `id`, and an
in-process provider that returns a result with no envelope at all must still be
able to sign what it serves.

**The identity is echoed in full, never implied by position.** A parallel array
indexed against `frames` would be smaller and unusable: a provider that
reorders, omits, or duplicates a frame would shift one frame's evidence onto
another, which is the substitution §6.5.2's identity binding exists to prevent.
It is also what a verifier needs — `provider_id` and `content_digest` are two of
the three inputs to the frame commitment and are recoverable from nowhere else.
This is the discipline §9's `FrameVerdict` already applies to `verify`.

**Both members of an entry are optional, and an entry with neither asserts
nothing.** The cheapest honest way to sign an answer is one signature over the
root plus a per-frame inclusion proof, with no per-frame signature at all;
requiring `attestation` would make that shape unrepresentable and force a
provider into *n* signatures to say what one says. A provider that signs frames
individually and publishes no root sends no proof. A host reading an entry that
carries neither treats the frame as *unattested* (F9).

**Inclusion proofs are carried, not derived, and F13 says why.** A host holding
the *complete* result set can rebuild every proof itself: it has all the
commitments. But a host that keeps a *subset* — after budget truncation,
cross-provider dedup, or ordinary composition — cannot, because the dropped
siblings' commitments are gone and no amount of later work recovers them. The
retained frames are then attested by a root nothing can recompute. So the proofs
have to be derivable at the one moment the whole set is in hand, and a provider
that ships them makes a host's correctness the host's own affair rather than a
step it has to know to take. The reasoning, and the wire-size argument against
mandating them, are in
[ADR 0014](./docs/adr/0014-attestations-on-the-wire.md).

**A truncated answer signs what it returned.** F12's root covers exactly the
frames in `result.frames`, never the candidate set the provider considered. A
root over frames the host never received is unverifiable by construction, and an
unverifiable root is worse than none: it looks like 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
Expand Down
20 changes: 16 additions & 4 deletions contextgraph-conformance/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,22 @@ path = "src/bin/contextgraph-example-docs.rs"

[dev-dependencies]
serde_json_canonicalizer.workspace = true
# The lifecycle-profile suite checks the *library's* record hashing and record
# attestation rather than a private copy of the rule, so a fixture and
# `contextgraph_types::record_attest` can never describe two different hashes
# (profile LF3). Repeating the dependency here adds only the feature.
# Both suites check the *library's* constructions rather than a private copy of
# either rule, so a fixture and `contextgraph_types` can never describe two
# different hashes.
#
# `record-attestation` is for the lifecycle-profile suite, which exercises
# record hashing and record attestation (profile LF3).
#
# `attestation` is for `tests/attestation_wire.rs`, which recomputes every
# commitment and checks every signature on the attestations shipped in
# `examples/` — verified, not asserted.
#
# Both are DEV features, so the published crate's ordinary dependency set is
# unchanged and contextgraph-types keeps its off-by-default cryptography, while
# `cargo test` still compiles the §6.5 constructions that nothing in CI reached
# before.
contextgraph-types = { path = "../contextgraph-types", version = ">=2.0.0", features = [
"attestation",
"record-attestation",
] }
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ fn main() {
frames,
truncated: false,
dropped_estimate: None,
..Default::default()
},
},
);
Expand Down
2 changes: 2 additions & 0 deletions contextgraph-conformance/src/host_conformance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,7 @@ fn frames_line() -> String {
frames: vec![frame("stdio-frame", 100)],
truncated: false,
dropped_estimate: None,
..Default::default()
},
};
serde_json::to_string(&env).expect("a fixed frames envelope always serializes")
Expand Down Expand Up @@ -851,6 +852,7 @@ impl ContextProvider for ProbeProvider {
frames: self.frames.clone(),
truncated: false,
dropped_estimate: None,
..Default::default()
})
}
}
Expand Down
Loading
Loading