Skip to main content

contextgraph_conformance/
lib.rs

1//! `contextgraph-conformance` — the public Context Graph Protocol conformance suite
2//! (`SPEC.md` §11).
3//!
4//! "Context Graph Protocol conformant" means *green on this suite for your declared capability
5//! set* — a checkable claim, which is what makes third-party adoption safe.
6//! [`run_conformance`] drives a provider through the protocol and returns a
7//! typed [`ConformanceReport`] with a pass/fail verdict per check and an
8//! evidence string for each, so a failure says exactly what was wrong.
9//!
10//! The checks (all against the frozen `contextgraph-types` contracts):
11//!
12//! - **handshake** — the provider completes the handshake and reports a
13//!   non-empty identity + capabilities (SPEC.md §3).
14//! - **consent-scope** — the provider's declared egress scopes are well-formed
15//!   and consistent with its `data_flow.egress` (`docs/context-reuse.md` §3):
16//!   no off-machine scope alongside `egress: false`, and custom scopes
17//!   namespaced.
18//! - **frame-validity** — queried frames pass `contextgraph-types` validation: score
19//!   in `[0, 1]`, a non-empty title, a non-empty `citation_label` (SPEC.md §6 —
20//!   "NEVER a bare uuid").
21//! - **verify-honesty** — a provider advertising `verify` answers `valid` for
22//!   frames it just served and `stale` when their digests are mutated
23//!   (`docs/context-reuse.md` §4). Skipped when `verify` is not advertised —
24//!   that is the declared fallback, not a failure.
25//! - **budget-honesty** — returned frames' summed `token_cost` never exceeds
26//!   the query budget, every declared cost is the canonical count, and the
27//!   frame count respects `max_frames` (SPEC.md §7 — "never lies about cost").
28//! - **as-of-temporal** — a query pinned with `as_of` gets back no frame whose
29//!   `valid_from` is after the pin, i.e. no content that was not yet true at
30//!   the pinned instant (SPEC.md §6.1). SHOULD-strength and one-sided: a
31//!   provider that returns fewer frames, or none, never fails it.
32//! - **shutdown-clean** — the provider tears down without error (SPEC.md §3).
33//! - **malformed-input-tolerance** — a garbage line is ignored, or errored with
34//!   code `bad_request`, never crashing the host (SPEC.md §R1). Staying alive is
35//!   the MUST; the structured `bad_request` code is the SHOULD this check now
36//!   inspects (#9), so an arbitrary error no longer passes. Wire-level, so it
37//!   applies to stdio providers.
38//! - **embedding-fingerprint** — a provider declaring an
39//!   `embeddings_fingerprint` rejects a query embedding whose length
40//!   contradicts its declared dimension with `bad_request` (SPEC.md §E1). A
41//!   SHOULD, gated on the provider declaring a fingerprint; wire-level, so like
42//!   the malformed probe it applies to stdio providers.
43//! - **provenance-fixture-consistency** — every `file` provenance digest the
44//!   provider serves matches the bytes on disk it names, re-read and re-hashed
45//!   by the host ([`contextgraph_host::verify_file_provenance`], §6.2/§F5). A
46//!   grammatically valid digest that hashes *wrong* — a stale or forged claim —
47//!   is caught here, where §F5's grammar check cannot see it. Host-local: a link
48//!   to files this host cannot read is skipped, not failed.
49//!
50//! The suite is deliberately adversarial: pointed at a provider that lies
51//! about costs, emits an out-of-range score, omits a citation label, or dies
52//! mid-query, the matching check fails loudly. The bundled `contextgraph-example-docs`
53//! fixture has `--misbehave` flags that trip each one, proving the suite
54//! catches a broken provider (task deliverable).
55//!
56//! The **host** side of the protocol has binding rules too, which the
57//! provider-facing checks above cannot exercise. Those live in
58//! [`host_conformance`], the dual suite: [`run_host_conformance`] drives the
59//! reference [`Host`] against adversarial in-process providers and asserts it
60//! upholds them (`SPEC.md` §11.1; issue #14).
61//!
62//! Both of those suites certify code in *this* repository. A third,
63//! [`composition_conformance`], is for code that is not: it takes a
64//! [`ComposingHost`] and certifies **someone else's** composition layer — the step
65//! above [`Host::query_all`] that turns a fan-out across several providers into
66//! the one frame set that reaches a prompt. That step is where a downstream host
67//! makes its own calls about a shared budget, and neither suite above can see it:
68//! three providers each returning one honest 400-token frame against a
69//! 1000-token query are individually conformant and jointly 200 over. Run
70//! [`run_composition_conformance`] against your own host;
71//! [`ReferenceComposingHost`] is the worked example that passes it.
72
73use contextgraph_host::{
74    ConsentRecord, ContextProvider, DigestVerification, DropReason, Host, HostError,
75    RawStdioConnection, frame_kind_name, verify_file_provenance,
76};
77use contextgraph_types::capability::fingerprint_dimensions;
78use contextgraph_types::{
79    Capabilities, ConsentReceipt, ContextQuery, ContextQueryResult, ErrorCode, FrameId, FrameKind,
80    Grantor, ProviderInfo,
81};
82
83pub mod composition_conformance;
84pub mod host_conformance;
85mod report;
86
87pub use composition_conformance::{
88    CCHECK_BUDGET_BOUND, CCHECK_DETERMINISM, CCHECK_QUARANTINE, CCHECK_TOTAL_PARTITION,
89    ComposingHost, Composition, ExcludedFrame, ReferenceComposingHost, run_composition_conformance,
90};
91pub use host_conformance::{
92    HCHECK_BUDGET_DROP, HCHECK_COMPOSITION_AUDIT, HCHECK_CONSENT_GATE, HCHECK_CONTENT_QUOTING,
93    HCHECK_CRASH_ISOLATION, HCHECK_FRAME_LIMIT, HCHECK_PROVENANCE_BYTES, HCHECK_SCOPE_RECEIPT,
94    HCHECK_VERSION_REJECT, run_host_conformance,
95};
96pub use report::{CheckResult, CheckStatus, ConformanceReport};
97
98/// The stable check names, so reports and callers agree on identifiers.
99pub const CHECK_HANDSHAKE: &str = "handshake";
100pub const CHECK_CONSENT_SCOPE: &str = "consent-scope";
101pub const CHECK_FRAME_VALIDITY: &str = "frame-validity";
102pub const CHECK_VERIFY_HONESTY: &str = "verify-honesty";
103pub const CHECK_BUDGET_HONESTY: &str = "budget-honesty";
104pub const CHECK_AS_OF: &str = "as-of-temporal";
105pub const CHECK_SHUTDOWN: &str = "shutdown-clean";
106pub const CHECK_MALFORMED: &str = "malformed-input-tolerance";
107pub const CHECK_EMBEDDING_FINGERPRINT: &str = "embedding-fingerprint";
108pub const CHECK_CORRELATION: &str = "correlation";
109pub const CHECK_KINDS_FILTER: &str = "kinds-filter";
110pub const CHECK_ANCHOR_RELEVANCE: &str = "anchor-relevance";
111pub const CHECK_PROVENANCE_FIXTURE_CONSISTENCY: &str = "provenance-fixture-consistency";
112
113/// How to reach the provider under test. `contextgraph-inspect` builds one of these
114/// from its CLI arguments; tests build them directly.
115pub enum ProviderTarget {
116    /// A child-process provider: `program` plus `args`.
117    Stdio { program: String, args: Vec<String> },
118    /// A remote provider at `url`.
119    Http { url: String },
120    /// An already-constructed in-process provider (e.g. a built-in).
121    InProcess(Box<dyn ContextProvider>),
122}
123
124impl ProviderTarget {
125    /// A one-line human description of the target, for the report header.
126    pub fn describe(&self) -> String {
127        match self {
128            ProviderTarget::Stdio { program, args } => {
129                if args.is_empty() {
130                    format!("stdio: {program}")
131                } else {
132                    format!("stdio: {program} {}", args.join(" "))
133                }
134            }
135            ProviderTarget::Http { url } => format!("http: {url}"),
136            ProviderTarget::InProcess(provider) => format!("in-process: {}", provider.id()),
137        }
138    }
139}
140
141/// Run the full conformance suite against a provider, returning a typed
142/// report. Never panics: every failure mode becomes a failing check with
143/// evidence.
144pub async fn run_conformance(target: ProviderTarget) -> ConformanceReport {
145    let description = target.describe();
146
147    // Capture stdio spawn info before `target` is consumed — the malformed
148    // probe needs a second, independent connection to the same program.
149    let stdio_probe = match &target {
150        ProviderTarget::Stdio { program, args } => Some((program.clone(), args.clone())),
151        _ => None,
152    };
153
154    let mut checks = Vec::new();
155
156    match build_host(target).await {
157        Ok((host, id, info, caps)) => {
158            if info.name.trim().is_empty() || info.version.trim().is_empty() {
159                checks.push(CheckResult::fail(
160                    CHECK_HANDSHAKE,
161                    format!(
162                        "provider identity incomplete: name='{}' version='{}'",
163                        info.name, info.version
164                    ),
165                ));
166            } else {
167                checks.push(CheckResult::pass(
168                    CHECK_HANDSHAKE,
169                    describe_handshake(&info, &caps),
170                ));
171            }
172            checks.push(check_consent_scopes(&info));
173            run_query_and_shutdown_checks(host, &id, &caps, &mut checks).await;
174        }
175        Err(error) => {
176            checks.push(CheckResult::fail(
177                CHECK_HANDSHAKE,
178                format!("could not establish provider: {error}"),
179            ));
180            for name in [
181                CHECK_FRAME_VALIDITY,
182                CHECK_VERIFY_HONESTY,
183                CHECK_CONSENT_SCOPE,
184                CHECK_BUDGET_HONESTY,
185                CHECK_AS_OF,
186                CHECK_KINDS_FILTER,
187                CHECK_ANCHOR_RELEVANCE,
188                CHECK_PROVENANCE_FIXTURE_CONSISTENCY,
189                CHECK_SHUTDOWN,
190            ] {
191                checks.push(CheckResult::skip(name, "handshake failed"));
192            }
193        }
194    }
195
196    match stdio_probe {
197        Some((program, args)) => {
198            checks.push(malformed_stdio_probe(&program, &args).await);
199            checks.push(embedding_fingerprint_stdio_probe(&program, &args).await);
200            checks.push(correlation_stdio_probe(&program, &args).await);
201        }
202        None => {
203            checks.push(CheckResult::skip(
204                CHECK_MALFORMED,
205                "wire-level malformed-input probe applies to stdio providers only",
206            ));
207            checks.push(CheckResult::skip(
208                CHECK_EMBEDDING_FINGERPRINT,
209                "wire-level §E1 bad_request probe applies to stdio providers only",
210            ));
211            checks.push(CheckResult::skip(
212                CHECK_CORRELATION,
213                "wire-level §H4 id-echo probe applies to stdio providers only",
214            ));
215        }
216    }
217
218    ConformanceReport {
219        target: description,
220        checks,
221    }
222}
223
224/// Stand up a one-provider host for the target and read back the provider's
225/// negotiated identity + capabilities. Records consent for an egress
226/// provider under test — running the suite *is* the consent to its declared
227/// flow, so it isn't spuriously gated.
228async fn build_host(
229    target: ProviderTarget,
230) -> Result<(Host, String, ProviderInfo, Capabilities), HostError> {
231    let mut host = Host::new();
232    let (id, info, caps) = match target {
233        ProviderTarget::Stdio { program, args } => {
234            let id = "provider-under-test".to_string();
235            host.add_stdio(id.clone(), &program, &args).await?;
236            capture_identity(&host, &id)?
237        }
238        ProviderTarget::Http { url } => {
239            let id = "provider-under-test".to_string();
240            host.add_http(id.clone(), url, None).await?;
241            capture_identity(&host, &id)?
242        }
243        ProviderTarget::InProcess(provider) => {
244            let id = provider.id().to_string();
245            let info = provider.info().clone();
246            let caps = provider.capabilities().clone();
247            host.register(provider);
248            (id, info, caps)
249        }
250    };
251
252    // Running the suite *is* consent to the provider's declared flow, so it
253    // isn't spuriously gated. Record the legacy boolean consent, and a receipt
254    // for every off-machine egress scope the provider declares (the scope gate).
255    if info.data_flow.egress {
256        host.record_consent(ConsentRecord::new(
257            id.clone(),
258            info.data_flow.clone(),
259            "conformance run under test",
260        ));
261    }
262    for scope in info.data_flow.off_machine_scopes() {
263        host.record_receipt(ConsentReceipt::new(
264            id.clone(),
265            &info,
266            scope.clone(),
267            Grantor::Policy("conformance-suite".into()),
268            "2026-07-21T00:00:00Z",
269        ));
270    }
271
272    Ok((host, id, info, caps))
273}
274
275fn capture_identity(
276    host: &Host,
277    id: &str,
278) -> Result<(String, ProviderInfo, Capabilities), HostError> {
279    let provider = host
280        .provider(id)
281        .ok_or_else(|| HostError::UnknownProvider(id.to_string()))?;
282    Ok((
283        id.to_string(),
284        provider.info().clone(),
285        provider.capabilities().clone(),
286    ))
287}
288
289async fn run_query_and_shutdown_checks(
290    host: Host,
291    id: &str,
292    caps: &Capabilities,
293    checks: &mut Vec<CheckResult>,
294) {
295    let query = sample_query();
296    match host.query_provider(id, &query).await {
297        Ok(result) => {
298            let (ok, evidence) = check_frames(&result);
299            checks.push(CheckResult::from_bool(CHECK_FRAME_VALIDITY, ok, evidence));
300            checks.push(check_verify_honesty(&host, id, caps, &result).await);
301
302            let (budget_ok, budget_evidence) = check_budget(&result, &query);
303            checks.push(CheckResult::from_bool(
304                CHECK_BUDGET_HONESTY,
305                budget_ok,
306                budget_evidence,
307            ));
308        }
309        Err(error) => {
310            let evidence = format!("query failed: {error}");
311            checks.push(CheckResult::fail(CHECK_FRAME_VALIDITY, evidence.clone()));
312            checks.push(CheckResult::fail(CHECK_VERIFY_HONESTY, evidence.clone()));
313            checks.push(CheckResult::fail(CHECK_BUDGET_HONESTY, evidence));
314        }
315    }
316
317    // The temporal probe fires its own `as_of`-pinned query, so it stands on
318    // its own regardless of how the unpinned query above fared. The §Q1 probe
319    // is independent for the same reason — it narrows `kinds`, which the
320    // unfiltered query above deliberately never does.
321    checks.push(check_as_of(&host, id).await);
322    checks.push(check_kinds_filter(&host, id, caps).await);
323    checks.push(check_anchor_relevance(&host, id, caps).await);
324    checks.push(check_provenance_fixture_consistency(&host, id).await);
325
326    let results = host.shutdown().await;
327    match results.iter().find(|(pid, _)| pid == id) {
328        Some((_, Ok(()))) => checks.push(CheckResult::pass(
329            CHECK_SHUTDOWN,
330            "provider acknowledged shutdown and tore down cleanly",
331        )),
332        Some((_, Err(error))) => checks.push(CheckResult::fail(
333            CHECK_SHUTDOWN,
334            format!("shutdown error: {error}"),
335        )),
336        None => checks.push(CheckResult::fail(
337            CHECK_SHUTDOWN,
338            "provider vanished before shutdown could be attempted",
339        )),
340    }
341}
342
343/// Suffix appended to a real digest to simulate a mutated source. Derived from
344/// the provider's own digest, so it is guaranteed to differ from it while
345/// staying vanishingly unlikely to collide with any digest the provider
346/// actually serves.
347const MUTATED_SUFFIX: &str = "-contextgraph-conformance-mutated";
348
349/// Probe `context/verify` honesty (`docs/context-reuse.md` §4, requirement V1).
350///
351/// A provider's digest is opaque and provider-declared, so only the provider
352/// can say whether an identity still names its current bytes — which means the
353/// suite cannot check the *answer*, only that the provider **distinguishes**.
354/// So it asks twice about frames the provider just served:
355///
356/// 1. with the **real** digests it returned — an honest provider says `valid`;
357/// 2. with those digests **mutated** — from the provider's side this is
358///    indistinguishable from a source that changed underneath the host, and an
359///    honest provider says `stale`.
360///
361/// A provider that rubber-stamps everything `valid` fails the second ask; one
362/// that advertises `verify` but can never vouch for anything fails the first.
363/// Both are caught without the suite needing to mutate a real source.
364///
365/// Skipped — not failed — when the provider does not advertise `verify`: that
366/// is the declared capability-gated fallback (V3), and the host re-queries
367/// instead.
368async fn check_verify_honesty(
369    host: &Host,
370    id: &str,
371    caps: &Capabilities,
372    result: &ContextQueryResult,
373) -> CheckResult {
374    if !caps.verify {
375        return CheckResult::skip(
376            CHECK_VERIFY_HONESTY,
377            "provider does not advertise `verify`; a host falls back to re-querying its frames (§4)",
378        );
379    }
380
381    let held: Vec<FrameId> = result
382        .frames
383        .iter()
384        .filter(|frame| frame.content_digest.is_some())
385        .map(|frame| FrameId::new(id, frame.id.clone(), frame.content_digest.clone()))
386        .collect();
387    if held.is_empty() {
388        return CheckResult::skip(
389            CHECK_VERIFY_HONESTY,
390            "provider served no frame carrying a `content_digest`, so nothing is verifiable (§1 D4)",
391        );
392    }
393
394    // Ask 1: the real digests. Every frame the provider just served must
395    // verify valid — otherwise it cannot vouch for its own output.
396    let unchanged = host.verify_frames(&held).await;
397    if !unchanged.dropped.is_empty() {
398        let detail: Vec<String> = unchanged
399            .dropped
400            .iter()
401            .map(|dropped| format!("{} => {:?}", dropped.frame.frame_id, dropped.reason))
402            .collect();
403        return CheckResult::fail(
404            CHECK_VERIFY_HONESTY,
405            format!(
406                "provider advertises `verify` but did not answer `valid` for {} of {} frame(s) it had just served with unchanged digests: {}",
407                unchanged.dropped.len(),
408                held.len(),
409                detail.join(", ")
410            ),
411        );
412    }
413
414    // Ask 2: the same frames with mutated digests — what a changed source
415    // looks like from the provider's side.
416    let mutated: Vec<FrameId> = held
417        .iter()
418        .map(|frame| {
419            FrameId::new(
420                id,
421                frame.frame_id.clone(),
422                frame
423                    .content_digest
424                    .as_ref()
425                    .map(|digest| format!("{digest}{MUTATED_SUFFIX}")),
426            )
427        })
428        .collect();
429    let changed = host.verify_frames(&mutated).await;
430
431    if !changed.retained.is_empty() {
432        return CheckResult::fail(
433            CHECK_VERIFY_HONESTY,
434            format!(
435                "provider answered `valid` for {} frame(s) whose content digest it never served — a rubber stamp that lets a host cite stale evidence",
436                changed.retained.len()
437            ),
438        );
439    }
440    let not_stale: Vec<String> = changed
441        .dropped
442        .iter()
443        .filter(|dropped| !matches!(dropped.reason, DropReason::Stale { .. }))
444        .map(|dropped| format!("{} => {:?}", dropped.frame.frame_id, dropped.reason))
445        .collect();
446    if !not_stale.is_empty() {
447        return CheckResult::fail(
448            CHECK_VERIFY_HONESTY,
449            format!(
450                "a digest mismatch on a frame the provider still serves MUST verify `stale` (§4 V1); got: {}",
451                not_stale.join(", ")
452            ),
453        );
454    }
455
456    CheckResult::pass(
457        CHECK_VERIFY_HONESTY,
458        format!(
459            "provider verified {n} unchanged frame(s) `valid` and all {n} mutated digest(s) `stale`, carrying no frame bodies",
460            n = held.len()
461        ),
462    )
463}
464
465/// Wire-level probe: complete the handshake on a fresh connection, inject a
466/// malformed line, then send a valid query. A conforming provider either
467/// ignores the garbage and answers the query, or errors on it with code
468/// `bad_request` — and stays alive either way (SPEC.md §R1). A provider that
469/// dies on one bad line fails; so, now, does one that stays alive but reports an
470/// error *other* than `bad_request` — the code is read, not merely the fact of
471/// an error (#9), so the check can tell a well-formed rejection from an
472/// arbitrary failure.
473async fn malformed_stdio_probe(program: &str, args: &[String]) -> CheckResult {
474    let mut conn = match RawStdioConnection::spawn(program, args).await {
475        Ok(conn) => conn,
476        Err(error) => {
477            return CheckResult::fail(
478                CHECK_MALFORMED,
479                format!("could not spawn provider: {error}"),
480            );
481        }
482    };
483    if let Err(error) = conn.handshake().await {
484        return CheckResult::fail(
485            CHECK_MALFORMED,
486            format!("handshake failed before the probe could run: {error}"),
487        );
488    }
489    if let Err(error) = conn.send_raw_line("this is not valid json {{{\n").await {
490        return CheckResult::fail(
491            CHECK_MALFORMED,
492            format!("provider closed its input on a malformed line: {error}"),
493        );
494    }
495    if let Err(error) = conn
496        .send(&contextgraph_host::Envelope::Query {
497            id: None,
498            query: sample_query(),
499        })
500        .await
501    {
502        return CheckResult::fail(
503            CHECK_MALFORMED,
504            format!("provider died after a malformed line (before a valid query): {error}"),
505        );
506    }
507    match conn.recv().await {
508        Ok(contextgraph_host::Envelope::Frames { .. }) => CheckResult::pass(
509            CHECK_MALFORMED,
510            "provider ignored a malformed line and still answered a valid query",
511        ),
512        // §R1's SHOULD: staying alive is the MUST, but a *structured*
513        // `bad_request` is what lets a host tell "your line was malformed" from
514        // an arbitrary failure. Inspecting the code (as the §E1 probe does) is
515        // the whole point of #9 — passing on any error would leave the code
516        // unread and the distinction unmade.
517        Ok(contextgraph_host::Envelope::Error {
518            code: Some(ErrorCode::BadRequest),
519            message,
520            ..
521        }) => CheckResult::pass(
522            CHECK_MALFORMED,
523            format!(
524                "provider errored cleanly on malformed input with `bad_request` and stayed alive: {message}"
525            ),
526        ),
527        // Alive, but the error is not the `bad_request` §R1 recommends (a
528        // different code, or none at all). The MUST is met; the SHOULD is not,
529        // and an unstructured failure is exactly what structured codes exist to
530        // replace — so this is flagged.
531        Ok(contextgraph_host::Envelope::Error { code, message, .. }) => CheckResult::fail(
532            CHECK_MALFORMED,
533            format!(
534                "provider stayed alive but answered malformed input with `{}` rather than the `bad_request` §R1 recommends: {message}",
535                code.map(|c| c.to_string())
536                    .unwrap_or_else(|| "no code".to_string())
537            ),
538        ),
539        Ok(other) => CheckResult::fail(
540            CHECK_MALFORMED,
541            format!(
542                "provider replied to a valid query with an unexpected `{}` envelope",
543                contextgraph_host::envelope_kind(&other)
544            ),
545        ),
546        Err(HostError::ProviderCrashed { .. }) => CheckResult::fail(
547            CHECK_MALFORMED,
548            "provider crashed on a malformed line — it must error-or-ignore, not die",
549        ),
550        Err(error) => CheckResult::fail(
551            CHECK_MALFORMED,
552            format!("provider mishandled malformed input: {error}"),
553        ),
554    }
555}
556
557/// Wire-level probe for §E1: a provider that declares an
558/// `embeddings_fingerprint` **SHOULD** reject a query embedding whose length
559/// contradicts that fingerprint's dimension with `bad_request`, rather than
560/// scoring a vector from a different space into plausible-looking, meaningless
561/// similarity.
562///
563/// Driven on the raw wire like [`malformed_stdio_probe`], because the honest
564/// reply is an `error` envelope carrying a *code* — and the host's query path
565/// collapses that to a bare message, losing the `bad_request` §E1 names. Reading
566/// the code directly is what makes this checkable, and is why the probe is
567/// stdio-only (skipped for in-process/HTTP targets — a documented limitation).
568///
569/// Gated on the provider declaring a fingerprint: one that declares none has no
570/// dimension to contradict and is skipped, exactly as a provider that does not
571/// advertise `verify` skips `verify-honesty`.
572async fn embedding_fingerprint_stdio_probe(program: &str, args: &[String]) -> CheckResult {
573    let mut conn = match RawStdioConnection::spawn(program, args).await {
574        Ok(conn) => conn,
575        Err(error) => {
576            return CheckResult::fail(
577                CHECK_EMBEDDING_FINGERPRINT,
578                format!("could not spawn provider: {error}"),
579            );
580        }
581    };
582    let caps = match conn.handshake().await {
583        Ok((_, caps)) => caps,
584        Err(error) => {
585            return CheckResult::skip(
586                CHECK_EMBEDDING_FINGERPRINT,
587                format!("handshake failed before the §E1 probe could run: {error}"),
588            );
589        }
590    };
591    let Some(fingerprint) = caps.embeddings_fingerprint.clone() else {
592        return CheckResult::skip(
593            CHECK_EMBEDDING_FINGERPRINT,
594            "provider declares no embeddings_fingerprint, so §E1 has no dimension to contradict",
595        );
596    };
597    let Some(dimension) = fingerprint_dimensions(&fingerprint) else {
598        return CheckResult::skip(
599            CHECK_EMBEDDING_FINGERPRINT,
600            format!(
601                "fingerprint `{fingerprint}` declares no parseable dimension, so §E1 cannot be probed"
602            ),
603        );
604    };
605
606    // A length guaranteed to differ from the declared dimension — the
607    // wrong-space vector §E1 says to reject.
608    let wrong_len = if dimension == 1 { 2 } else { 1 };
609    let mut query = sample_query();
610    query.embedding = Some(vec![0.0; wrong_len]);
611    if let Err(error) = conn
612        .send(&contextgraph_host::Envelope::Query { id: None, query })
613        .await
614    {
615        return CheckResult::fail(
616            CHECK_EMBEDDING_FINGERPRINT,
617            format!("provider closed its input before the §E1 probe query: {error}"),
618        );
619    }
620    match conn.recv().await {
621        // The recommended reply: it named the request wrong with the code §E1
622        // specifies.
623        Ok(contextgraph_host::Envelope::Error {
624            code: Some(ErrorCode::BadRequest),
625            ..
626        }) => CheckResult::pass(
627            CHECK_EMBEDDING_FINGERPRINT,
628            format!(
629                "provider declares {fingerprint} ({dimension}-dim) and rejected a {wrong_len}-dim embedding with `bad_request` (§E1)"
630            ),
631        ),
632        // Refused, but not with the code §E1 recommends. Refusing at all is the
633        // load-bearing half of a SHOULD, so this passes with a note.
634        Ok(contextgraph_host::Envelope::Error { code, message, .. }) => CheckResult::pass(
635            CHECK_EMBEDDING_FINGERPRINT,
636            format!(
637                "provider rejected a {wrong_len}-dim embedding against {fingerprint} with `{}` rather than the `bad_request` §E1 recommends: {message}",
638                code.unwrap_or(ErrorCode::Internal)
639            ),
640        ),
641        // The violation: it *scored* a vector from a different space.
642        Ok(contextgraph_host::Envelope::Frames { .. }) => CheckResult::fail(
643            CHECK_EMBEDDING_FINGERPRINT,
644            format!(
645                "provider declares {fingerprint} ({dimension}-dim) but scored a {wrong_len}-dim embedding into frames instead of rejecting it — meaningless similarity from a different vector space (§E1)"
646            ),
647        ),
648        Ok(other) => CheckResult::fail(
649            CHECK_EMBEDDING_FINGERPRINT,
650            format!(
651                "provider answered the §E1 probe with an unexpected `{}` envelope",
652                contextgraph_host::envelope_kind(&other)
653            ),
654        ),
655        Err(HostError::ProviderCrashed { .. }) => CheckResult::fail(
656            CHECK_EMBEDDING_FINGERPRINT,
657            "provider crashed on a dimension-mismatched embedding — §E1 asks it to reply `bad_request`, not die",
658        ),
659        Err(error) => CheckResult::fail(
660            CHECK_EMBEDDING_FINGERPRINT,
661            format!("provider mishandled the §E1 probe: {error}"),
662        ),
663    }
664}
665
666/// The correlation id the §H4 probe sends. Deliberately distinctive so a
667/// provider that echoes *something* — a counter, its own id — fails rather
668/// than coincidentally matching.
669const CORRELATION_PROBE_ID: &str = "cgp-conformance-h4-7f3a";
670
671/// **§H4** — a provider declaring `capabilities.correlation` **MUST** echo a
672/// request's `id` verbatim on the corresponding `frames` or `error`.
673///
674/// This check exists because the guarantee was previously unenforceable from
675/// outside. H4's only witness was the reference provider's
676/// `drop-correlation-id` misbehave mode, and that mode "went red" merely
677/// because dropping the id desynchronizes the host's demultiplexer and breaks
678/// every *other* check downstream. Nothing actually asserted the echo — so an
679/// external implementation (each of the three SDKs) could declare
680/// `correlation: true`, never echo an id, and pass the suite. Requiring the
681/// matching check in `conformance-red.sh` is what surfaced the hole.
682///
683/// The probe is raw-stdio rather than host-driven for the same reason the §E1
684/// probe is: the host layer *interprets* correlation (it demultiplexes on the
685/// id and raises `CorrelationMismatch`), so driving through it would test the
686/// host's reaction rather than the provider's wire behavior.
687async fn correlation_stdio_probe(program: &str, args: &[String]) -> CheckResult {
688    let mut conn = match RawStdioConnection::spawn(program, args).await {
689        Ok(conn) => conn,
690        Err(error) => {
691            return CheckResult::fail(
692                CHECK_CORRELATION,
693                format!("could not spawn provider: {error}"),
694            );
695        }
696    };
697    let caps = match conn.handshake().await {
698        Ok((_, caps)) => caps,
699        Err(error) => {
700            return CheckResult::skip(
701                CHECK_CORRELATION,
702                format!("handshake failed before the §H4 probe could run: {error}"),
703            );
704        }
705    };
706    if !caps.correlation {
707        // Not a failure: correlation is negotiated, and a lock-step provider
708        // that never claims it is conformant. H4 binds only those who declare.
709        return CheckResult::skip(
710            CHECK_CORRELATION,
711            "provider does not declare capabilities.correlation, so §H4 does not bind it",
712        );
713    }
714
715    let query = sample_query();
716    if let Err(error) = conn
717        .send(&contextgraph_host::Envelope::Query {
718            id: Some(CORRELATION_PROBE_ID.to_string()),
719            query,
720        })
721        .await
722    {
723        return CheckResult::fail(
724            CHECK_CORRELATION,
725            format!("provider closed its input before the §H4 probe query: {error}"),
726        );
727    }
728
729    let reply = match conn.recv().await {
730        Ok(reply) => reply,
731        Err(error) => {
732            return CheckResult::fail(
733                CHECK_CORRELATION,
734                format!("provider mishandled the §H4 probe: {error}"),
735            );
736        }
737    };
738
739    let kind = contextgraph_host::envelope_kind(&reply);
740    // `frames` and `error` both answer a query, and H4 binds both.
741    match reply.correlation_id() {
742        Some(echoed) if echoed == CORRELATION_PROBE_ID => CheckResult::pass(
743            CHECK_CORRELATION,
744            format!(
745                "provider declares correlation and echoed the request id verbatim on its `{kind}` reply (§H4)"
746            ),
747        ),
748        Some(echoed) => CheckResult::fail(
749            CHECK_CORRELATION,
750            format!(
751                "provider declares correlation but echoed `{echoed}` on its `{kind}` reply instead of the request's `{CORRELATION_PROBE_ID}` — a host demultiplexing on the id would match this reply to the wrong request (§H4)"
752            ),
753        ),
754        None if matches!(reply, contextgraph_host::Envelope::Frames { .. })
755            || matches!(reply, contextgraph_host::Envelope::Error { .. }) =>
756        {
757            CheckResult::fail(
758                CHECK_CORRELATION,
759                format!(
760                    "provider declares correlation but its `{kind}` reply carried no id — the host cannot match it to the request it answers, so the connection is forced back to lock-step (§H4)"
761                ),
762            )
763        }
764        None => CheckResult::fail(
765            CHECK_CORRELATION,
766            format!("provider answered the §H4 probe with an unexpected `{kind}` envelope"),
767        ),
768    }
769}
770
771/// The instant the `as_of` probe pins retrieval to (`SPEC.md` §6.1). Chosen to
772/// fall *between* the reference fixture's two frame validity windows, so an
773/// honest provider's pinned answer is observably narrower than its unpinned one.
774const AS_OF_PIN: &str = "2026-07-01T00:00:00Z";
775
776/// Probe `as_of` temporal pinning (`SPEC.md` §6.1, §F4). `as_of` pins retrieval
777/// to an instant; a frame whose `valid_from` is strictly after the pin is
778/// content that was not yet true then — exactly what the pin exists to keep out
779/// of the answer.
780///
781/// SHOULD-strength and deliberately one-sided: it never penalizes a provider for
782/// returning *fewer* frames (or none) under a pin, because implementing
783/// time-travel retrieval is optional. It fails only on a frame the provider
784/// *did* return whose `valid_from` provably postdates the pin — a temporal lie
785/// no matter how sophisticated the provider's time handling. Comparison is
786/// lexicographic on the UTC strings, which is chronological because the
787/// timestamp profile admits one spelling per instant (§6.1). A provider serving
788/// no timestamped content trivially passes.
789async fn check_as_of(host: &Host, id: &str) -> CheckResult {
790    match host.query_provider(id, &as_of_query()).await {
791        Ok(result) => {
792            let not_yet_valid: Vec<String> = result
793                .frames
794                .iter()
795                .filter_map(|frame| {
796                    frame
797                        .valid_from
798                        .as_deref()
799                        .filter(|valid_from| *valid_from > AS_OF_PIN)
800                        .map(|valid_from| format!("{} (valid_from={valid_from})", frame.id))
801                })
802                .collect();
803            if not_yet_valid.is_empty() {
804                CheckResult::pass(
805                    CHECK_AS_OF,
806                    format!(
807                        "as_of={AS_OF_PIN}: none of the {} returned frame(s) is dated after the pin",
808                        result.frames.len()
809                    ),
810                )
811            } else {
812                CheckResult::fail(
813                    CHECK_AS_OF,
814                    format!(
815                        "provider returned {} frame(s) whose valid_from is after as_of={AS_OF_PIN} — content that was not yet true at the pinned instant (§6.1): {}",
816                        not_yet_valid.len(),
817                        not_yet_valid.join(", ")
818                    ),
819                )
820            }
821        }
822        Err(error) => CheckResult::fail(CHECK_AS_OF, format!("as_of query failed: {error}")),
823    }
824}
825
826/// **§Q1** — a non-empty `kinds` is a filter a provider must honor.
827///
828/// The probe narrows to a single kind drawn from the provider's *own* declared
829/// `capabilities.query.kinds`, so it can never be an unfair request: the
830/// provider said it serves this kind. Every returned frame must then be of that
831/// kind.
832///
833/// Worth stating why this check did not exist until now: [`sample_query`] sends
834/// `kinds: []`, so every provider was only ever asked the unfiltered question,
835/// and a provider that ignored the filter entirely passed the whole suite. All
836/// four reference implementations did exactly that.
837async fn check_kinds_filter(host: &Host, id: &str, caps: &Capabilities) -> CheckResult {
838    let Some(declared) = caps.query.kinds.first() else {
839        return CheckResult::skip(
840            CHECK_KINDS_FILTER,
841            "provider declares no query kinds, so §Q1 has no kind to narrow to",
842        );
843    };
844    let Some(kind) = frame_kind_from_wire(declared) else {
845        return CheckResult::skip(
846            CHECK_KINDS_FILTER,
847            format!(
848                "provider declares kind `{declared}`, which is outside the closed FrameKind vocabulary, so §Q1 cannot be probed"
849            ),
850        );
851    };
852
853    let query = ContextQuery {
854        kinds: vec![kind],
855        ..sample_query()
856    };
857    match host.query_provider(id, &query).await {
858        Ok(result) => {
859            let off_kind: Vec<String> = result
860                .frames
861                .iter()
862                .filter(|frame| frame.kind != kind)
863                .map(|frame| format!("{} (kind={})", frame.id, frame_kind_name(frame.kind)))
864                .collect();
865            if off_kind.is_empty() {
866                CheckResult::pass(
867                    CHECK_KINDS_FILTER,
868                    format!(
869                        "kinds=[{declared}]: all {} returned frame(s) are of the requested kind (§Q1)",
870                        result.frames.len()
871                    ),
872                )
873            } else {
874                CheckResult::fail(
875                    CHECK_KINDS_FILTER,
876                    format!(
877                        "provider returned {} frame(s) outside the requested kinds=[{declared}] — content the host explicitly excluded, charged against its budget (§Q1): {}",
878                        off_kind.len(),
879                        off_kind.join(", ")
880                    ),
881                )
882            }
883        }
884        Err(error) => CheckResult::fail(
885            CHECK_KINDS_FILTER,
886            format!("kinds-filtered query failed: {error}"),
887        ),
888    }
889}
890
891/// Parse a declared capability kind string back into the closed [`FrameKind`]
892/// vocabulary. `None` for anything outside it — a provider may declare an
893/// extension kind, and §Q1 simply has nothing to say about it.
894fn frame_kind_from_wire(kind: &str) -> Option<FrameKind> {
895    match kind {
896        "snippet" => Some(FrameKind::Snippet),
897        "symbol" => Some(FrameKind::Symbol),
898        "fact" => Some(FrameKind::Fact),
899        "doc" => Some(FrameKind::Doc),
900        "memory" => Some(FrameKind::Memory),
901        "episode" => Some(FrameKind::Episode),
902        "graph" => Some(FrameKind::Graph),
903        _ => None,
904    }
905}
906
907/// **§G3/§G4** — a graph-declaring provider must actually do something with
908/// `anchors`.
909///
910/// The graph is what the protocol is *named* for, and it was the least
911/// exercised surface in the repo: the reference fixture declared
912/// `graph: false` and served frames with `relations: vec![]`, so G1 and G2
913/// passed vacuously (no edges to validate) and G3's boost was never witnessed
914/// at all.
915///
916/// The probe first asks an unanchored question to discover a URI the provider
917/// actually serves, then re-asks anchored on it. Discovering the anchor from
918/// the provider's own output is what keeps this fair: the suite never invents a
919/// URI and demands the provider know it.
920async fn check_anchor_relevance(host: &Host, id: &str, caps: &Capabilities) -> CheckResult {
921    if !caps.graph {
922        return CheckResult::skip(
923            CHECK_ANCHOR_RELEVANCE,
924            "provider does not declare capabilities.graph, so §G3/§G4 do not bind it",
925        );
926    }
927
928    let baseline = match host.query_provider(id, &sample_query()).await {
929        Ok(result) => result,
930        Err(error) => {
931            return CheckResult::fail(
932                CHECK_ANCHOR_RELEVANCE,
933                format!("baseline query failed: {error}"),
934            );
935        }
936    };
937
938    // Prefer a one-hop anchor (a relation target): it proves the provider
939    // traverses edges, not merely compares its own `uri`.
940    let anchor = baseline
941        .frames
942        .iter()
943        .find_map(|frame| frame.relations.first().map(|r| r.target_uri.clone()))
944        .or_else(|| baseline.frames.iter().find_map(|frame| frame.uri.clone()));
945    let Some(anchor) = anchor else {
946        return CheckResult::skip(
947            CHECK_ANCHOR_RELEVANCE,
948            "provider declares graph but served no frame carrying a uri or a relation target to anchor on",
949        );
950    };
951
952    let anchored_query = ContextQuery {
953        anchors: vec![anchor.clone()],
954        ..sample_query()
955    };
956    match host.query_provider(id, &anchored_query).await {
957        Ok(result) => {
958            let anchored: Vec<&contextgraph_types::ContextFrame> = result
959                .frames
960                .iter()
961                .filter(|frame| frame_is_anchored(frame, &anchor))
962                .collect();
963            if anchored.is_empty() {
964                return CheckResult::fail(
965                    CHECK_ANCHOR_RELEVANCE,
966                    format!(
967                        "provider declares capabilities.graph but returned no frame anchored on `{anchor}` — a URI drawn from its own previous answer (§G4)"
968                    ),
969                );
970            }
971            // G3 is a SHOULD, so ranking is reported rather than enforced: a
972            // provider that finds the anchored frame but orders it second is
973            // still conformant, and saying so is more honest than inventing a
974            // MUST the spec does not state.
975            let first_is_anchored = result
976                .frames
977                .first()
978                .is_some_and(|frame| frame_is_anchored(frame, &anchor));
979            let ranking = if first_is_anchored {
980                "and ranked it first"
981            } else {
982                "though it did not rank it first (§G3 is a SHOULD)"
983            };
984            CheckResult::pass(
985                CHECK_ANCHOR_RELEVANCE,
986                format!(
987                    "anchored on `{anchor}`: provider returned {} anchored frame(s) {ranking}",
988                    anchored.len()
989                ),
990            )
991        }
992        Err(error) => CheckResult::fail(
993            CHECK_ANCHOR_RELEVANCE,
994            format!("anchored query failed: {error}"),
995        ),
996    }
997}
998
999/// §G4's anchoring predicate: the frame's own `uri` (zero hops) or any labelled
1000/// edge's `target_uri` (one hop) equals the anchor.
1001fn frame_is_anchored(frame: &contextgraph_types::ContextFrame, anchor: &str) -> bool {
1002    frame.uri.as_deref() == Some(anchor) || frame.relations.iter().any(|r| r.target_uri == anchor)
1003}
1004
1005/// **§6.2/§F5 (bytes)** — every `file` provenance digest a provider serves must
1006/// match the bytes on disk it names.
1007///
1008/// The `frame-validity` §F5 check proves a provenance digest is *shaped* like a
1009/// sha256; only re-reading the file it addresses proves it is the *right* one.
1010/// This check re-reads each `file` provenance the provider serves and re-hashes
1011/// it with [`contextgraph_host::verify_file_provenance`], the host's own
1012/// byte-level verifier.
1013///
1014/// A definitive failure is a **`Mismatch`**: the bytes are here and hash to
1015/// something else — provenance forgery, or a fixture that drifted out of sync
1016/// with its own files. An **`Unreadable`** link (a `file://` this host cannot
1017/// see — an out-of-tree or remote provider) is *not* a failure: byte
1018/// verification is a host-local capability, and a provider is not broken because
1019/// its files do not sit on this machine. A provider serving no locally-readable
1020/// file provenance is therefore skipped, not failed — mirroring how
1021/// `verify-honesty` skips a provider that does not advertise `verify`.
1022async fn check_provenance_fixture_consistency(host: &Host, id: &str) -> CheckResult {
1023    let result = match host.query_provider(id, &sample_query()).await {
1024        Ok(result) => result,
1025        Err(error) => {
1026            return CheckResult::fail(
1027                CHECK_PROVENANCE_FIXTURE_CONSISTENCY,
1028                format!("query failed: {error}"),
1029            );
1030        }
1031    };
1032
1033    let mut verified = 0usize;
1034    let mut unreadable = 0usize;
1035    let mut mismatches = Vec::new();
1036    for frame in &result.frames {
1037        for (index, outcome) in verify_file_provenance(frame) {
1038            match outcome {
1039                DigestVerification::Verified => verified += 1,
1040                DigestVerification::Mismatch { expected, actual } => mismatches.push(format!(
1041                    "{} provenance[{index}] declared {expected} but its bytes hash to {actual}",
1042                    frame.id
1043                )),
1044                DigestVerification::Unreadable { .. } => unreadable += 1,
1045                DigestVerification::NotFileProvenance => {}
1046            }
1047        }
1048    }
1049
1050    if !mismatches.is_empty() {
1051        return CheckResult::fail(
1052            CHECK_PROVENANCE_FIXTURE_CONSISTENCY,
1053            format!(
1054                "{} file-provenance digest(s) do not match the bytes they name — a stale or forged digest that passes §F5's grammar but not its bytes (§6.2): {}",
1055                mismatches.len(),
1056                mismatches.join("; ")
1057            ),
1058        );
1059    }
1060    if verified == 0 {
1061        return CheckResult::skip(
1062            CHECK_PROVENANCE_FIXTURE_CONSISTENCY,
1063            format!(
1064                "no locally re-readable file provenance to verify ({unreadable} link(s) name files this host cannot see); §6.2 byte-verification is host-local"
1065            ),
1066        );
1067    }
1068    CheckResult::pass(
1069        CHECK_PROVENANCE_FIXTURE_CONSISTENCY,
1070        format!(
1071            "re-read and re-hashed {verified} file-provenance digest(s) against the bytes on disk — all match (§6.2)"
1072        ),
1073    )
1074}
1075
1076/// The [`sample_query`] pinned to [`AS_OF_PIN`] — the query the temporal probe
1077/// fires. Everything else is held equal so only the pin varies.
1078fn as_of_query() -> ContextQuery {
1079    ContextQuery {
1080        as_of: Some(AS_OF_PIN.into()),
1081        ..sample_query()
1082    }
1083}
1084
1085/// The query the suite probes every provider with — no `kinds` filter, so any
1086/// provider is asked for its best frames (SPEC.md §5).
1087pub fn sample_query() -> ContextQuery {
1088    ContextQuery {
1089        goal: "conformance probe: return your most relevant frames".into(),
1090        query_text: Some("conformance probe".into()),
1091        embedding: None,
1092        kinds: vec![],
1093        anchors: vec![],
1094        max_frames: 8,
1095        max_tokens: 4096,
1096        as_of: None,
1097        representation_preferences: vec![],
1098    }
1099}
1100
1101/// Validate a query result's frames against the `ContextFrame` contract
1102/// (SPEC.md §6). Returns `(passed, evidence)`. Zero frames is permitted — a
1103/// provider may simply have nothing relevant.
1104pub fn check_frames(result: &ContextQueryResult) -> (bool, String) {
1105    if result.frames.is_empty() {
1106        return (
1107            true,
1108            "provider returned 0 frames (permitted — nothing relevant to the probe)".into(),
1109        );
1110    }
1111
1112    let mut problems = Vec::new();
1113    for (i, frame) in result.frames.iter().enumerate() {
1114        if !frame.has_valid_score() {
1115            problems.push(format!("frame[{i}] score {} is outside [0,1]", frame.score));
1116        }
1117        if frame.title.trim().is_empty() {
1118            problems.push(format!("frame[{i}] has an empty title"));
1119        }
1120        match &frame.citation_label {
1121            Some(label) if !label.trim().is_empty() => {}
1122            _ => problems.push(format!(
1123                "frame[{i}] is missing a citation_label (§F3 — never a bare id)"
1124            )),
1125        }
1126        // §P1–P3: a frame must not lie about how it carries its content — a
1127        // `reference` carrying inline content, a `compact` missing its
1128        // canonical hash. `representation_invariants` names the exact breach.
1129        // The predicate shipped in PR #42 with no caller; this is the caller.
1130        if let Err(violation) = frame.representation_invariants() {
1131            problems.push(format!("frame[{i}] {violation} (§P1–P3)"));
1132        }
1133        // §F4: temporal fields must be in the protocol's timestamp profile.
1134        // Naming the offending field is what makes this actionable — before
1135        // this check, `"valid_from": "last tuesday"` was fully conformant and
1136        // the bi-temporal guarantee was unfalsifiable.
1137        for field in frame.invalid_temporal_fields() {
1138            problems.push(format!(
1139                "frame[{i}] field `{field}` is not an RFC 3339 UTC timestamp (§F4)"
1140            ));
1141        }
1142        // §D1: the frame's own content_digest, when present, must be in the
1143        // protocol's digest form. Like §G2 this was listed as verified here and
1144        // read by nothing — so the digest that anchors deterministic
1145        // composition, usage reports and `context/verify` was held to a looser
1146        // standard than the §F5 provenance digests immediately below it.
1147        if !frame.has_usable_content_digest() {
1148            problems.push(format!(
1149                "frame[{i}] content_digest is present but not `sha256:<64 lowercase hex>` (§D1)"
1150            ));
1151        }
1152        // §F5: file provenance must carry a well-formed digest, since that is
1153        // the only provenance a host can independently re-read and verify.
1154        for index in frame.provenance_with_unusable_digests() {
1155            problems.push(format!(
1156                "frame[{i}] provenance[{index}] addresses a file but its digest is missing or not `sha256:<64 lowercase hex>` (§F5)"
1157            ));
1158        }
1159        // §G1/§G2: a graph edge must be citable by a human label, and must
1160        // actually point somewhere. G2 was listed as "Verified by
1161        // frame-validity" while no code read `target_uri` at all — the exact
1162        // self-attestation §11.1 rejects. It is verified here now.
1163        for (edge_index, edge) in frame.relations.iter().enumerate() {
1164            if !edge.has_display_name() {
1165                problems.push(format!(
1166                    "frame[{i}] relation[{edge_index}] `{}` has no display_name (§G1 — an edge is surfaced by label, never a raw id)",
1167                    edge.rel
1168                ));
1169            }
1170            if !edge.has_target_uri() {
1171                problems.push(format!(
1172                    "frame[{i}] relation[{edge_index}] `{}` has an empty target_uri (§G2 — an edge to nowhere is not an edge)",
1173                    edge.rel
1174                ));
1175            }
1176        }
1177    }
1178
1179    if problems.is_empty() {
1180        (
1181            true,
1182            format!(
1183                "{} frame(s) — scores in [0,1], titles, citation labels, honest representations, RFC 3339 timestamps, well-formed digests, labelled and targeted relations",
1184                result.frames.len()
1185            ),
1186        )
1187    } else {
1188        (false, problems.join("; "))
1189    }
1190}
1191
1192/// Validate a query result against the budget contract (`SPEC.md` §B1, §B3,
1193/// §B4). Returns `(passed, evidence)`.
1194///
1195/// Three distinct promises, deliberately checked separately so a failure says
1196/// which one broke:
1197///
1198/// - **§B1** the declared costs sum within `max_tokens`;
1199/// - **§B3** each declared cost equals the canonical count for its content —
1200///   this is what turned the check from arithmetic into truth;
1201/// - **§B4** the frame count respects `max_frames`.
1202pub fn check_budget(result: &ContextQueryResult, query: &ContextQuery) -> (bool, String) {
1203    let mut problems = Vec::new();
1204
1205    let declared = result.total_token_cost();
1206    if declared > query.max_tokens as u64 {
1207        problems.push(format!(
1208            "declared cost {declared} exceeds the query budget of {} (§B1)",
1209            query.max_tokens
1210        ));
1211    }
1212
1213    let dishonest = result.frames_with_dishonest_cost();
1214    if !dishonest.is_empty() {
1215        let canonical = result.canonical_token_cost();
1216        problems.push(format!(
1217            "{} frame(s) misdeclare token_cost — {} (§B3); declared total {declared}, canonical total {canonical}",
1218            dishonest.len(),
1219            dishonest.join(", ")
1220        ));
1221    }
1222
1223    if !result.respects_frame_limit(query.max_frames) {
1224        problems.push(format!(
1225            "returned {} frames against max_frames={} (§B4)",
1226            result.frames.len(),
1227            query.max_frames
1228        ));
1229    }
1230
1231    if problems.is_empty() {
1232        (
1233            true,
1234            format!(
1235                "{} frame(s), {declared} tokens within the {} budget; every declared cost matches its canonical count",
1236                result.frames.len(),
1237                query.max_tokens
1238            ),
1239        )
1240    } else {
1241        (false, problems.join("; "))
1242    }
1243}
1244
1245/// Validate a provider's declared egress scopes against its `data_flow`
1246/// (`docs/context-reuse.md` §3, requirement C5): every scope must be well-formed
1247/// (custom scopes namespaced), and no off-machine scope may be declared with
1248/// `egress: false`. A scope-lying provider — one claiming local posture while
1249/// naming a destination that leaves — fails here.
1250fn check_consent_scopes(info: &ProviderInfo) -> CheckResult {
1251    if info.data_flow.scopes_consistent() {
1252        let scopes: Vec<&str> = info
1253            .data_flow
1254            .egress_scopes
1255            .iter()
1256            .map(|scope| scope.as_str())
1257            .collect();
1258        CheckResult::pass(
1259            CHECK_CONSENT_SCOPE,
1260            format!(
1261                "declared egress scopes {scopes:?} are well-formed and consistent with egress={}",
1262                info.data_flow.egress
1263            ),
1264        )
1265    } else {
1266        CheckResult::fail(
1267            CHECK_CONSENT_SCOPE,
1268            format!(
1269                "egress scopes {:?} are inconsistent with egress={}: an off-machine scope alongside egress=false, or a non-namespaced custom scope (§3, C5)",
1270                info.data_flow
1271                    .egress_scopes
1272                    .iter()
1273                    .map(|scope| scope.as_str())
1274                    .collect::<Vec<_>>(),
1275                info.data_flow.egress
1276            ),
1277        )
1278    }
1279}
1280
1281fn describe_handshake(info: &ProviderInfo, caps: &Capabilities) -> String {
1282    format!(
1283        "provider '{}' v{} — data-flow reads={} writes={} egress={}; query kinds={:?}, graph={}",
1284        info.name,
1285        info.version,
1286        info.data_flow.reads,
1287        info.data_flow.writes,
1288        info.data_flow.egress,
1289        caps.query.kinds,
1290        caps.graph,
1291    )
1292}