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
85 changes: 81 additions & 4 deletions contextgraph-conformance/src/bin/contextgraph-example-docs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ use clap::{Parser, ValueEnum};
use contextgraph_host::wire::Envelope;
use contextgraph_types::capability::QueryCapability;
use contextgraph_types::{
Capabilities, ContextFrame, ContextQueryResult, DataFlow, EgressScope, FrameKind,
PROTOCOL_VERSION, Provenance, ProviderInfo,
Capabilities, ContextFrame, ContextQueryResult, DataFlow, FrameKind, FrameVerdict,
PROTOCOL_VERSION, Provenance, ProviderInfo, Verdict, VerifyRequest, VerifyResponse,
};

/// Ways this fixture can deliberately violate the protocol, each tripping a
Expand All @@ -41,12 +41,26 @@ enum Misbehave {
/// Exit on receiving a malformed line (trips
/// `malformed-input-tolerance`).
CrashOnGarbage,
/// Answer `valid` to every `context/verify` entry without comparing
/// digests — the rubber stamp that makes reuse unsafe (trips
/// `verify-honesty`).
RubberStampVerify,
/// Advertise `verify` support at the handshake but answer `unknown` to
/// everything — a provider that claims a capability it does not have
/// (trips `verify-honesty`).
HollowVerify,
/// Declare an off-machine egress scope (`third-party-index`) alongside
/// `egress: false` — a scope that contradicts the data-flow posture
/// (trips `consent-scope`).
ScopeLie,
}

/// The digests this fixture currently serves, one per canned frame. Verify
/// answers are derived from these, so the fixture's `context/verify` verdicts
/// and the frames it serves can never drift apart.
const GETTING_STARTED_DIGEST: &str = "sha256:getting-started-v1";
const CONFIGURATION_DIGEST: &str = "sha256:configuration-v1";

#[derive(Parser)]
#[command(
name = "contextgraph-example-docs",
Expand Down Expand Up @@ -115,6 +129,21 @@ fn main() {
},
);
}
Envelope::Verify { request } => {
let response = match args.misbehave {
// Claims every held frame is still good without comparing
// a single digest — the lie `verify-honesty` catches.
Some(Misbehave::RubberStampVerify) => {
VerifyResponse::uniform(&request, Verdict::Valid)
}
// Advertises verify but can never vouch for anything.
Some(Misbehave::HollowVerify) => {
VerifyResponse::uniform(&request, Verdict::Unknown)
}
_ => verify_honestly(&request),
};
write_envelope(&mut stdout, &Envelope::Verified { response });
}
Envelope::Shutdown => std::process::exit(0),
// handshake_ack / frames / error are host→provider-invalid inputs;
// a provider ignores them.
Expand Down Expand Up @@ -163,9 +192,57 @@ fn capabilities() -> Capabilities {
graph: false,
embeddings_fingerprint: None,
subscribe: false,
// This fixture can compare a presented digest against the one it
// currently serves, so it advertises pull-based verification (§4). It
// cannot watch its sources, so it does not advertise `subscribe` — the
// exact provider shape verify exists for.
verify: true,
}
}

/// The digest this fixture serves for a frame id *right now*, or `None` if it
/// does not serve that frame at all.
fn current_digest(frame_id: &str) -> Option<&'static str> {
match frame_id {
"frm_getting_started" => Some(GETTING_STARTED_DIGEST),
"frm_configuration" => Some(CONFIGURATION_DIGEST),
_ => None,
}
}

/// Answer a `context/verify` request honestly, by comparing each presented
/// digest against the one this provider currently serves (§4).
///
/// This is the whole provider-side contract: the digest is provider-declared
/// and opaque, so only the provider can say whether the bytes behind an
/// identity still match. A digest that differs from the current one is exactly
/// what a mutated source looks like from here.
fn verify_honestly(request: &VerifyRequest) -> VerifyResponse {
VerifyResponse::new(
request
.frames
.iter()
.map(|frame| {
let verdict = match current_digest(&frame.frame_id) {
// Never served, or no longer served: nothing to revalidate.
None => Verdict::Gone,
Some(current) => match frame.content_digest.as_deref() {
// Nothing presented to compare against.
None => Verdict::Unknown,
Some(presented) if presented == current => Verdict::Valid,
// The source moved on. Offer the current digest so the
// host can tell what it would be re-fetching.
Some(_) => Verdict::Stale {
replacement_digest: Some(current.to_string()),
},
},
};
FrameVerdict::new(frame.clone(), verdict)
})
.collect(),
)
}

fn canned_frames(misbehave: Option<Misbehave>) -> Vec<ContextFrame> {
let bad_score = misbehave == Some(Misbehave::BadScore);
let empty_citation = misbehave == Some(Misbehave::EmptyCitation);
Expand All @@ -186,7 +263,7 @@ fn canned_frames(misbehave: Option<Misbehave>) -> Vec<ContextFrame> {
"Install the reference binding with `cargo add contextgraph-types`, then implement \
the four required methods."
.into(),
content_digest: Some("sha256:getting-started-v1".into()),
content_digest: Some(GETTING_STARTED_DIGEST.into()),
uri: Some("file:///docs/getting-started.md".into()),
score: if bad_score { 1.5 } else { 0.82 },
token_cost,
Expand Down Expand Up @@ -216,7 +293,7 @@ fn canned_frames(misbehave: Option<Misbehave>) -> Vec<ContextFrame> {
content: "Providers declare their data-flow direction at the handshake so hosts can \
gate consent before sending any query."
.into(),
content_digest: Some("sha256:configuration-v1".into()),
content_digest: Some(CONFIGURATION_DIGEST.into()),
uri: Some("file:///docs/configuration.md".into()),
score: 0.61,
token_cost,
Expand Down
141 changes: 138 additions & 3 deletions contextgraph-conformance/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
//! - **frame-validity** — queried frames pass `contextgraph-types` validation: score
//! in `[0, 1]`, a non-empty title, a non-empty `citation_label` (§3.4 —
//! "NEVER a bare uuid").
//! - **verify-honesty** — a provider advertising `verify` answers `valid` for
//! frames it just served and `stale` when their digests are mutated
//! (`docs/context-reuse.md` §4). Skipped when `verify` is not advertised —
//! that is the declared fallback, not a failure.
//! - **budget-honesty** — returned frames' summed `token_cost` never exceeds
//! the query budget (§3.3 — "never lies about cost").
//! - **shutdown-clean** — the provider tears down without error (§3.2).
Expand All @@ -44,6 +48,7 @@ pub use report::{CheckResult, CheckStatus, ConformanceReport};
pub const CHECK_HANDSHAKE: &str = "handshake";
pub const CHECK_CONSENT_SCOPE: &str = "consent-scope";
pub const CHECK_FRAME_VALIDITY: &str = "frame-validity";
pub const CHECK_VERIFY_HONESTY: &str = "verify-honesty";
pub const CHECK_BUDGET_HONESTY: &str = "budget-honesty";
pub const CHECK_SHUTDOWN: &str = "shutdown-clean";
pub const CHECK_MALFORMED: &str = "malformed-input-tolerance";
Expand Down Expand Up @@ -108,16 +113,17 @@ pub async fn run_conformance(target: ProviderTarget) -> ConformanceReport {
));
}
checks.push(check_consent_scopes(&info));
run_query_and_shutdown_checks(host, &id, &mut checks).await;
run_query_and_shutdown_checks(host, &id, &caps, &mut checks).await;
}
Err(error) => {
checks.push(CheckResult::fail(
CHECK_HANDSHAKE,
format!("could not establish provider: {error}"),
));
for name in [
CHECK_CONSENT_SCOPE,
CHECK_FRAME_VALIDITY,
CHECK_VERIFY_HONESTY,
CHECK_CONSENT_SCOPE,
CHECK_BUDGET_HONESTY,
CHECK_SHUTDOWN,
] {
Expand Down Expand Up @@ -205,12 +211,18 @@ fn capture_identity(
))
}

async fn run_query_and_shutdown_checks(host: Host, id: &str, checks: &mut Vec<CheckResult>) {
async fn run_query_and_shutdown_checks(
host: Host,
id: &str,
caps: &Capabilities,
checks: &mut Vec<CheckResult>,
) {
let query = sample_query();
match host.query_provider(id, &query).await {
Ok(result) => {
let (ok, evidence) = check_frames(&result);
checks.push(CheckResult::from_bool(CHECK_FRAME_VALIDITY, ok, evidence));
checks.push(check_verify_honesty(&host, id, caps, &result).await);

if result.respects_budget(query.max_tokens) {
checks.push(CheckResult::pass(
Expand All @@ -236,6 +248,7 @@ async fn run_query_and_shutdown_checks(host: Host, id: &str, checks: &mut Vec<Ch
Err(error) => {
let evidence = format!("query failed: {error}");
checks.push(CheckResult::fail(CHECK_FRAME_VALIDITY, evidence.clone()));
checks.push(CheckResult::fail(CHECK_VERIFY_HONESTY, evidence.clone()));
checks.push(CheckResult::fail(CHECK_BUDGET_HONESTY, evidence));
}
}
Expand All @@ -257,6 +270,128 @@ async fn run_query_and_shutdown_checks(host: Host, id: &str, checks: &mut Vec<Ch
}
}

/// Suffix appended to a real digest to simulate a mutated source. Derived from
/// the provider's own digest, so it is guaranteed to differ from it while
/// staying vanishingly unlikely to collide with any digest the provider
/// actually serves.
const MUTATED_SUFFIX: &str = "-contextgraph-conformance-mutated";

/// Probe `context/verify` honesty (`docs/context-reuse.md` §4, requirement V1).
///
/// A provider's digest is opaque and provider-declared, so only the provider
/// can say whether an identity still names its current bytes — which means the
/// suite cannot check the *answer*, only that the provider **distinguishes**.
/// So it asks twice about frames the provider just served:
///
/// 1. with the **real** digests it returned — an honest provider says `valid`;
/// 2. with those digests **mutated** — from the provider's side this is
/// indistinguishable from a source that changed underneath the host, and an
/// honest provider says `stale`.
///
/// A provider that rubber-stamps everything `valid` fails the second ask; one
/// that advertises `verify` but can never vouch for anything fails the first.
/// Both are caught without the suite needing to mutate a real source.
///
/// Skipped — not failed — when the provider does not advertise `verify`: that
/// is the declared capability-gated fallback (V3), and the host re-queries
/// instead.
async fn check_verify_honesty(
host: &Host,
id: &str,
caps: &Capabilities,
result: &ContextQueryResult,
) -> CheckResult {
if !caps.verify {
return CheckResult::skip(
CHECK_VERIFY_HONESTY,
"provider does not advertise `verify`; a host falls back to re-querying its frames (§4)",
);
}

let held: Vec<FrameId> = result
.frames
.iter()
.filter(|frame| frame.content_digest.is_some())
.map(|frame| FrameId::new(id, frame.id.clone(), frame.content_digest.clone()))
.collect();
if held.is_empty() {
return CheckResult::skip(
CHECK_VERIFY_HONESTY,
"provider served no frame carrying a `content_digest`, so nothing is verifiable (§1 D4)",
);
}

// Ask 1: the real digests. Every frame the provider just served must
// verify valid — otherwise it cannot vouch for its own output.
let unchanged = host.verify_frames(&held).await;
if !unchanged.dropped.is_empty() {
let detail: Vec<String> = unchanged
.dropped
.iter()
.map(|dropped| format!("{} => {:?}", dropped.frame.frame_id, dropped.reason))
.collect();
return CheckResult::fail(
CHECK_VERIFY_HONESTY,
format!(
"provider advertises `verify` but did not answer `valid` for {} of {} frame(s) it had just served with unchanged digests: {}",
unchanged.dropped.len(),
held.len(),
detail.join(", ")
),
);
}

// Ask 2: the same frames with mutated digests — what a changed source
// looks like from the provider's side.
let mutated: Vec<FrameId> = held
.iter()
.map(|frame| {
FrameId::new(
id,
frame.frame_id.clone(),
frame
.content_digest
.as_ref()
.map(|digest| format!("{digest}{MUTATED_SUFFIX}")),
)
})
.collect();
let changed = host.verify_frames(&mutated).await;

if !changed.retained.is_empty() {
return CheckResult::fail(
CHECK_VERIFY_HONESTY,
format!(
"provider answered `valid` for {} frame(s) whose content digest it never served — a rubber stamp that lets a host cite stale evidence",
changed.retained.len()
),
);
}
let not_stale: Vec<String> = changed
.dropped
.iter()
.filter(|dropped| !matches!(dropped.reason, DropReason::Stale { .. }))
.map(|dropped| format!("{} => {:?}", dropped.frame.frame_id, dropped.reason))
.collect();
if !not_stale.is_empty() {
return CheckResult::fail(
CHECK_VERIFY_HONESTY,
format!(
"a digest mismatch on a frame the provider still serves MUST verify `stale` (§4 V1); got: {}",
not_stale.join(", ")
),
);
}

CheckResult::pass(
CHECK_VERIFY_HONESTY,
format!(
"provider verified {n} unchanged frame(s) `valid` and all {n} mutated digest(s) `stale`, carrying no frame bodies",
n = held.len()
),
)
}

/// Wire-level probe: complete the handshake on a fresh connection, inject a
/// malformed line, then send a valid query. A conforming provider ignores or
/// cleanly errors on the garbage and stays alive to answer the query; a
Expand Down
25 changes: 25 additions & 0 deletions contextgraph-conformance/tests/conformance_suite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ async fn a_well_behaved_provider_is_fully_conformant() {
CHECK_HANDSHAKE,
CHECK_CONSENT_SCOPE,
CHECK_FRAME_VALIDITY,
CHECK_VERIFY_HONESTY,
CHECK_BUDGET_HONESTY,
CHECK_SHUTDOWN,
CHECK_MALFORMED,
Expand Down Expand Up @@ -111,3 +112,27 @@ async fn an_incompatible_protocol_version_fails_the_handshake() {
CheckStatus::Skipped
);
}

#[tokio::test]
async fn rubber_stamping_every_verify_as_valid_fails_verify_honesty() {
// The dangerous lie: a provider that answers `valid` without comparing
// digests lets a host go on citing evidence that changed underneath it.
let report = run_conformance(target(&["--misbehave", "rubber-stamp-verify"])).await;
assert!(!report.passed());
assert_eq!(status_of(&report, CHECK_VERIFY_HONESTY), CheckStatus::Fail);
// Nothing else is disturbed — the frames it serves are still well-formed
// and honestly costed, so only the verify check catches this.
for name in [CHECK_HANDSHAKE, CHECK_FRAME_VALIDITY, CHECK_BUDGET_HONESTY] {
assert_eq!(status_of(&report, name), CheckStatus::Pass, "{name}");
}
}

#[tokio::test]
async fn advertising_verify_while_vouching_for_nothing_fails_verify_honesty() {
// The other direction: a provider that claims the capability but answers
// `unknown` to everything cannot revalidate its own just-served frames.
let report = run_conformance(target(&["--misbehave", "hollow-verify"])).await;
assert!(!report.passed());
assert_eq!(status_of(&report, CHECK_VERIFY_HONESTY), CheckStatus::Fail);
assert_eq!(status_of(&report, CHECK_HANDSHAKE), CheckStatus::Pass);
}
Loading