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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,38 @@ which. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1
host. Wire-compatible; Rust API breaking (#5, #6, #11).

### Fixed
- **`SPEC.md` §9's `verify` example no longer fails the schema `SPEC.md` ships.**
Both envelopes carried `"id": "v1"`, but §3.2 grants an `id` only to
`query`/`frames`/`error`, the reference `Envelope::Verify`/`Verified` have no
such field, and the schema is `additionalProperties: false` — the example was
invalid against the protocol's own definition. The `id`s are removed;
`verify` correlates by full frame identity, not by envelope id. Root cause:
`schema/validate-examples.py` checked `examples/` but never `SPEC.md`, so the
one example surface with no machine check was the one that drifted. It now
validates every fenced `jsonc` block in `SPEC.md` too (comments and documented
placeholders normalized away, structure checked), and CI's existing `schema`
job therefore catches this class of drift.
- **JSON Schema: an ordinary `ContextQuery` was un-representable.** `kinds` and
`anchors` were listed as `required` while both are
`skip_serializing_if = "Vec::is_empty"` in the reference type, so an
unfiltered, unanchored query — what a host sends when it wants anything
relevant — failed schema validation. Exactly the bug fixed for `ContextFrame`
below, one type over. A test now asserts an ordinary query satisfies the
schema's own `required` array, and a cross-audit of all 16 shared types
confirms no other type demands a field its serializer elides.
- **§G2 and §D1 are now actually verified, not merely asserted.** Both named
`frame-validity` as their verifier while neither `target_uri` nor the frame's
own `content_digest` was read by any check — the self-attestation §11.1
exists to rule out. `check_frames` now rejects a relation with an empty
`target_uri` (§G2) and a present-but-malformed `content_digest` (§D1); its
evidence string had claimed "well-formed digests" while accepting
`sha256:abc`. §D1 was found by auditing the other ten rules that cite
`frame-validity` after §G2 turned out to be unenforced; the remaining nine
were confirmed enforced.
- **`contextgraph-host::wire` docs no longer invert a MUST NOT.** The module
said concurrency is "negotiated by observation, not by a capability flag",
contradicting `SPEC.md` §3.2 and the shipped `Capabilities::correlation`: a
host **MUST NOT** send an `id` to a provider that did not declare correlation.
- JSON Schema: a `ContextFrame`'s `required` is now exactly what the reference
serializer always emits (`id`, `kind`, `title`, `score`, `token_cost`).
`provenance` and `relations` were listed as globally required but are
Expand Down
4 changes: 2 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,14 +482,14 @@ a notification-shaped 1.x addition (§13) and is not defined here.

```jsonc
// host → provider
{ "type": "verify", "id": "v1",
{ "type": "verify",
"request": { "frames": [
{ "provider_id": "code-graph", "frame_id": "frm_retry",
"content_digest": "sha256:<64 hex>" }
] } }

// provider → host
{ "type": "verified", "id": "v1",
{ "type": "verified",
"response": { "verdicts": [
{ "frame": { "provider_id": "code-graph", "frame_id": "frm_retry",
"content_digest": "sha256:<64 hex>" },
Expand Down
23 changes: 21 additions & 2 deletions contextgraph-conformance/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1013,29 +1013,48 @@ pub fn check_frames(result: &ContextQueryResult) -> (bool, String) {
"frame[{i}] field `{field}` is not an RFC 3339 UTC timestamp (§F4)"
));
}
// §D1: the frame's own content_digest, when present, must be in the
// protocol's digest form. Like §G2 this was listed as verified here and
// read by nothing — so the digest that anchors deterministic
// composition, usage reports and `context/verify` was held to a looser
// standard than the §F5 provenance digests immediately below it.
if !frame.has_usable_content_digest() {
problems.push(format!(
"frame[{i}] content_digest is present but not `sha256:<64 lowercase hex>` (§D1)"
));
}
// §F5: file provenance must carry a well-formed digest, since that is
// the only provenance a host can independently re-read and verify.
for index in frame.provenance_with_unusable_digests() {
problems.push(format!(
"frame[{i}] provenance[{index}] addresses a file but its digest is missing or not `sha256:<64 lowercase hex>` (§F5)"
));
}
// §G1: a graph edge must be citable by a human label.
// §G1/§G2: a graph edge must be citable by a human label, and must
// actually point somewhere. G2 was listed as "Verified by
// frame-validity" while no code read `target_uri` at all — the exact
// self-attestation §11.1 rejects. It is verified here now.
for (edge_index, edge) in frame.relations.iter().enumerate() {
if !edge.has_display_name() {
problems.push(format!(
"frame[{i}] relation[{edge_index}] `{}` has no display_name (§G1 — an edge is surfaced by label, never a raw id)",
edge.rel
));
}
if !edge.has_target_uri() {
problems.push(format!(
"frame[{i}] relation[{edge_index}] `{}` has an empty target_uri (§G2 — an edge to nowhere is not an edge)",
edge.rel
));
}
}
}

if problems.is_empty() {
(
true,
format!(
"{} frame(s) — scores in [0,1], titles, citation labels, honest representations, RFC 3339 timestamps, well-formed digests, labelled relations",
"{} frame(s) — scores in [0,1], titles, citation labels, honest representations, RFC 3339 timestamps, well-formed digests, labelled and targeted relations",
result.frames.len()
),
)
Expand Down
95 changes: 94 additions & 1 deletion contextgraph-conformance/tests/golden_fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};

use contextgraph_conformance::check_frames;
use contextgraph_types::{
ContextFrame, ContextQuery, ContextQueryResult, PROTOCOL_VERSION, Representation,
ContextFrame, ContextQuery, ContextQueryResult, PROTOCOL_VERSION, Relation, Representation,
};
use serde::Deserialize;
use serde_json::{Map, Value};
Expand Down Expand Up @@ -293,6 +293,99 @@ fn assert_rejected_citation(frame: ContextFrame, label: &str) {
);
}

/// §G2 — `target_uri` **MUST** be a non-empty URI.
///
/// The spec listed this as "Verified by `frame-validity`" while `target_uri`
/// appeared nowhere in the conformance or validation code: the rule was
/// asserted, not checked, which is precisely what §11.1 says a conformance
/// suite must not do. This test is the check that makes the claim true, written
/// adversarially — a suite that only ever passes proves nothing about its
/// ability to catch a broken provider.
#[test]
fn frame_validity_catches_a_relation_that_points_nowhere() {
let base: ContextFrame = read_fixture("context-frame.compact.valid.json");

let with_edge = |target_uri: &str| {
let mut frame = base.clone();
frame.relations = vec![Relation {
rel: "doc.documents".into(),
target_uri: target_uri.into(),
display_name: Some("Retry policy".into()),
}];
ContextQueryResult {
frames: vec![frame],
truncated: false,
dropped_estimate: None,
}
};

// Control: the fixture is conformant, and stays so with a real edge — so a
// failure below is attributable to `target_uri` and nothing else.
let (passed, evidence) = check_frames(&with_edge("file:///repo/docs/retry.md"));
assert!(passed, "a well-formed edge was rejected: {evidence}");

// The breach: an edge that is labelled (§G1 satisfied) but dangling.
for empty in ["", " "] {
let (passed, evidence) = check_frames(&with_edge(empty));
assert!(
!passed,
"a relation with target_uri {empty:?} passed frame-validity — §G2 is unenforced again"
);
assert!(
evidence.contains("target_uri") && evidence.contains("§G2"),
"evidence must name the field and the rule, got: {evidence}"
);
}
}

/// §D1 — `content_digest`, when present, **MUST** match `sha256:<64 lowercase
/// hex>`.
///
/// Found by auditing the other ten rules that name `frame-validity` as their
/// verifier after §G2 turned out to be unenforced. This one was unenforced too,
/// and the evidence string `check_frames` returned claimed "well-formed
/// digests" while accepting `sha256:abc` — the very placeholder
/// `validate::tests::rejects_the_placeholder_digest_the_repo_used_in_examples`
/// exists to reject.
#[test]
fn frame_validity_catches_a_malformed_content_digest() {
let base: ContextFrame = read_fixture("context-frame.compact.valid.json");
let with_digest = |digest: Option<&str>| {
let mut frame = base.clone();
frame.content_digest = digest.map(str::to_string);
ContextQueryResult {
frames: vec![frame],
truncated: false,
dropped_estimate: None,
}
};

// Control: the fixture's real digest passes, so a failure below is
// attributable to the digest form and nothing else.
let (passed, evidence) = check_frames(&with_digest(base.content_digest.as_deref()));
assert!(
passed,
"a well-formed content_digest was rejected: {evidence}"
);

for malformed in [
"sha256:abc".to_string(), // the repo's own placeholder
format!("sha256:{}", "A".repeat(64)), // uppercase hex — a false-negative compare
format!("md5:{}", "a".repeat(64)), // undeclared algorithm
"a".repeat(64), // no algorithm prefix
] {
let (passed, evidence) = check_frames(&with_digest(Some(&malformed)));
assert!(
!passed,
"content_digest {malformed:?} passed frame-validity — §D1 is unenforced again"
);
assert!(
evidence.contains("content_digest") && evidence.contains("§D1"),
"evidence must name the field and the rule, got: {evidence}"
);
}
}

#[test]
fn manifest_has_strict_coverage_and_correct_file_hashes() {
let directory = fixture_dir();
Expand Down
59 changes: 54 additions & 5 deletions contextgraph-conformance/tests/ingest_conformance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ use contextgraph_host::wire::{Envelope, decode_line, encode_line};
use contextgraph_host::{ContextProvider, IngestConfig, PasteIngest, ingest_paste};
use contextgraph_types::{ContextQuery, Representation};

/// The exact `required` key set the JSON Schema declares for a `ContextFrame`,
/// read from the schema in the repo so the test tracks the schema rather than a
/// The exact `required` key set the JSON Schema declares for `definition`, read
/// from the schema in the repo so the test tracks the schema rather than a
/// hard-coded copy of it.
fn schema_required_frame_keys() -> Vec<String> {
fn schema_required_keys(definition: &str) -> Vec<String> {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root")
Expand All @@ -32,14 +32,63 @@ fn schema_required_frame_keys() -> Vec<String> {
let raw =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
let schema: serde_json::Value = serde_json::from_str(&raw).expect("schema is valid JSON");
schema["$defs"]["ContextFrame"]["required"]
schema["$defs"][definition]["required"]
.as_array()
.expect("$defs.ContextFrame.required is an array")
.unwrap_or_else(|| panic!("$defs.{definition}.required is an array"))
.iter()
.map(|v| v.as_str().expect("a required key is a string").to_string())
.collect()
}

fn schema_required_frame_keys() -> Vec<String> {
schema_required_keys("ContextFrame")
}

/// The most ordinary query there is — no kind filter, no anchors — must be
/// representable in conformant JSON.
///
/// The same bug this module's header describes for `ContextFrame` was still
/// live one type over: `kinds` and `anchors` are `skip_serializing_if =
/// "Vec::is_empty"`, so the reference serializer omits them, while the schema
/// listed both as `required`. An unfiltered, unanchored query — what a host
/// sends when it wants anything relevant — therefore failed schema validation.
///
/// Asserting against the schema's own `required` array rather than a literal
/// list is what makes this a guard instead of a snapshot: re-adding either key
/// to `required` fails here immediately.
#[test]
fn an_unfiltered_unanchored_query_satisfies_the_schemas_required_keys() {
let query = ContextQuery {
goal: "why does the retry loop give up".into(),
query_text: None,
embedding: None,
kinds: Vec::new(),
anchors: Vec::new(),
max_frames: 8,
max_tokens: 2000,
as_of: None,
representation_preferences: Vec::new(),
};

let value = serde_json::to_value(&query).expect("query serializes");
let object = value.as_object().expect("a query serializes to an object");

// Precondition: the elision this guards is real, not incidental.
assert!(
!object.contains_key("kinds") && !object.contains_key("anchors"),
"the reference serializer no longer elides empty kinds/anchors — \
this guard's premise changed: {value}"
);

for key in schema_required_keys("ContextQuery") {
assert!(
object.contains_key(&key),
"an ordinary query omits schema-required key `{key}`, making it \
un-representable in conformant JSON: {value}"
);
}
}

/// The ADR's motivating paste: a noisy log, a data table, an attached note, and
/// a directory reference — every evidence kind plus an anchor.
fn motivating_paste() -> PasteIngest {
Expand Down
18 changes: 15 additions & 3 deletions contextgraph-host/src/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,21 @@
//! (`docs/sketches/push-invalidation.md`).
//!
//! `id` is optional so that a provider written against an earlier revision
//! stays conformant: a host that has not seen a provider echo an `id` **MUST**
//! fall back to lock-step. Concurrency is negotiated by observation, not by a
//! capability flag.
//! stays conformant: it is queried in lock-step and is fully conformant.
//!
//! Correlation is negotiated **explicitly**, via
//! [`Capabilities::correlation`](contextgraph_types::Capabilities::correlation)
//! — a host **MUST NOT** send an `id` to a provider that did not declare it
//! (`SPEC.md` §3.2). This paragraph previously said the opposite ("negotiated
//! by observation, not by a capability flag"), which predates the capability
//! and inverts a MUST NOT: a reply carrying no `id` is ambiguous between "does
//! not implement correlation" and "implements it incorrectly", and a guarantee
//! whose violation is indistinguishable from legitimate behaviour cannot be
//! checked.
//!
//! Note that `verify`/`verified` carry no `id` at all: a verdict echoes the
//! frame identity it answers *in full*, so those exchanges correlate by
//! matching rather than by envelope id (`SPEC.md` §9).

use contextgraph_types::{
Capabilities, ContextQuery, ContextQueryResult, ErrorCode, ProviderInfo, VerifyRequest,
Expand Down
Loading
Loading