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//! - **attestation** — every detached [provenance
50//!   attestation](contextgraph_types::ProvenanceAttestation) a provider serves
51//!   is recomputed from the frame in hand and verified against a key the
52//!   handshake published (`SPEC.md` §6.5, F6–F9). Where
53//!   `provenance-fixture-consistency` proves a digest matches its bytes, this
54//!   proves somebody *signed* for those bytes — the digest and the frame come
55//!   from the same unauthenticated party, so §6.2 is satisfied in full by a
56//!   provider that fabricated both. A provider that publishes no attester key
57//!   and serves no attestation passes: §6.5 makes the construction mandatory
58//!   and the signing optional.
59//!
60//! The suite is deliberately adversarial: pointed at a provider that lies
61//! about costs, emits an out-of-range score, omits a citation label, or dies
62//! mid-query, the matching check fails loudly. The bundled `contextgraph-example-docs`
63//! fixture has `--misbehave` flags that trip each one, proving the suite
64//! catches a broken provider (task deliverable).
65//!
66//! The **host** side of the protocol has binding rules too, which the
67//! provider-facing checks above cannot exercise. Those live in
68//! [`host_conformance`], the dual suite: [`run_host_conformance`] drives the
69//! reference [`Host`] against adversarial in-process providers and asserts it
70//! upholds them (`SPEC.md` §11.1; issue #14).
71//!
72//! Both of those suites certify code in *this* repository. A third,
73//! [`composition_conformance`], is for code that is not: it takes a
74//! [`ComposingHost`] and certifies **someone else's** composition layer — the step
75//! above [`Host::query_all`] that turns a fan-out across several providers into
76//! the one frame set that reaches a prompt. That step is where a downstream host
77//! makes its own calls about a shared budget, and neither suite above can see it:
78//! three providers each returning one honest 400-token frame against a
79//! 1000-token query are individually conformant and jointly 200 over. Run
80//! [`run_composition_conformance`] against your own host;
81//! [`ReferenceComposingHost`] is the worked example that passes it.
82
83use contextgraph_host::{
84    AttesterKey, ConsentRecord, ContextProvider, DigestVerification, DropReason, Host, HostError,
85    RawStdioConnection, frame_kind_name, verify_file_provenance,
86};
87use contextgraph_types::capability::fingerprint_dimensions;
88use contextgraph_types::{
89    AttestationVerdict, Capabilities, ConsentReceipt, ContextQuery, ContextQueryResult, ErrorCode,
90    FrameId, FrameKind, Grantor, ProviderInfo, verify_frame_attestation, verify_frame_inclusion,
91};
92
93pub mod composition_conformance;
94pub mod host_conformance;
95mod report;
96
97pub use composition_conformance::{
98    CCHECK_BUDGET_BOUND, CCHECK_DETERMINISM, CCHECK_QUARANTINE, CCHECK_TOTAL_PARTITION,
99    ComposingHost, Composition, ExcludedFrame, ReferenceComposingHost, run_composition_conformance,
100};
101pub use host_conformance::{
102    HCHECK_BUDGET_DROP, HCHECK_COMPOSITION_AUDIT, HCHECK_CONSENT_GATE, HCHECK_CONTENT_QUOTING,
103    HCHECK_CRASH_ISOLATION, HCHECK_FRAME_LIMIT, HCHECK_PROVENANCE_BYTES, HCHECK_SCOPE_RECEIPT,
104    HCHECK_VERSION_REJECT, run_host_conformance,
105};
106pub use report::{CheckResult, CheckStatus, ConformanceReport};
107
108/// The stable check names, so reports and callers agree on identifiers.
109pub const CHECK_HANDSHAKE: &str = "handshake";
110pub const CHECK_CONSENT_SCOPE: &str = "consent-scope";
111pub const CHECK_FRAME_VALIDITY: &str = "frame-validity";
112pub const CHECK_VERIFY_HONESTY: &str = "verify-honesty";
113pub const CHECK_BUDGET_HONESTY: &str = "budget-honesty";
114pub const CHECK_AS_OF: &str = "as-of-temporal";
115pub const CHECK_SHUTDOWN: &str = "shutdown-clean";
116pub const CHECK_MALFORMED: &str = "malformed-input-tolerance";
117pub const CHECK_EMBEDDING_FINGERPRINT: &str = "embedding-fingerprint";
118pub const CHECK_CORRELATION: &str = "correlation";
119pub const CHECK_KINDS_FILTER: &str = "kinds-filter";
120pub const CHECK_ANCHOR_RELEVANCE: &str = "anchor-relevance";
121pub const CHECK_PROVENANCE_FIXTURE_CONSISTENCY: &str = "provenance-fixture-consistency";
122pub const CHECK_ATTESTATION: &str = "attestation";
123
124/// How to reach the provider under test. `contextgraph-inspect` builds one of these
125/// from its CLI arguments; tests build them directly.
126pub enum ProviderTarget {
127    /// A child-process provider: `program` plus `args`.
128    Stdio { program: String, args: Vec<String> },
129    /// A remote provider at `url`.
130    Http { url: String },
131    /// An already-constructed in-process provider (e.g. a built-in).
132    InProcess(Box<dyn ContextProvider>),
133}
134
135impl ProviderTarget {
136    /// A one-line human description of the target, for the report header.
137    pub fn describe(&self) -> String {
138        match self {
139            ProviderTarget::Stdio { program, args } => {
140                if args.is_empty() {
141                    format!("stdio: {program}")
142                } else {
143                    format!("stdio: {program} {}", args.join(" "))
144                }
145            }
146            ProviderTarget::Http { url } => format!("http: {url}"),
147            ProviderTarget::InProcess(provider) => format!("in-process: {}", provider.id()),
148        }
149    }
150}
151
152/// Run the full conformance suite against a provider, returning a typed
153/// report. Never panics: every failure mode becomes a failing check with
154/// evidence.
155pub async fn run_conformance(target: ProviderTarget) -> ConformanceReport {
156    let description = target.describe();
157
158    // Capture stdio spawn info before `target` is consumed — the malformed
159    // probe needs a second, independent connection to the same program.
160    let stdio_probe = match &target {
161        ProviderTarget::Stdio { program, args } => Some((program.clone(), args.clone())),
162        _ => None,
163    };
164
165    let mut checks = Vec::new();
166
167    match build_host(target).await {
168        Ok((host, id, info, caps)) => {
169            if info.name.trim().is_empty() || info.version.trim().is_empty() {
170                checks.push(CheckResult::fail(
171                    CHECK_HANDSHAKE,
172                    format!(
173                        "provider identity incomplete: name='{}' version='{}'",
174                        info.name, info.version
175                    ),
176                ));
177            } else {
178                checks.push(CheckResult::pass(
179                    CHECK_HANDSHAKE,
180                    describe_handshake(&info, &caps),
181                ));
182            }
183            checks.push(check_consent_scopes(&info));
184            run_query_and_shutdown_checks(host, &id, &caps, &mut checks).await;
185        }
186        Err(error) => {
187            checks.push(CheckResult::fail(
188                CHECK_HANDSHAKE,
189                format!("could not establish provider: {error}"),
190            ));
191            for name in [
192                CHECK_FRAME_VALIDITY,
193                CHECK_VERIFY_HONESTY,
194                CHECK_CONSENT_SCOPE,
195                CHECK_BUDGET_HONESTY,
196                CHECK_AS_OF,
197                CHECK_KINDS_FILTER,
198                CHECK_ANCHOR_RELEVANCE,
199                CHECK_PROVENANCE_FIXTURE_CONSISTENCY,
200                CHECK_SHUTDOWN,
201            ] {
202                checks.push(CheckResult::skip(name, "handshake failed"));
203            }
204        }
205    }
206
207    match stdio_probe {
208        Some((program, args)) => {
209            checks.push(malformed_stdio_probe(&program, &args).await);
210            checks.push(embedding_fingerprint_stdio_probe(&program, &args).await);
211            checks.push(correlation_stdio_probe(&program, &args).await);
212            checks.push(attestation_stdio_probe(&program, &args).await);
213        }
214        None => {
215            checks.push(CheckResult::skip(
216                CHECK_MALFORMED,
217                "wire-level malformed-input probe applies to stdio providers only",
218            ));
219            checks.push(CheckResult::skip(
220                CHECK_EMBEDDING_FINGERPRINT,
221                "wire-level §E1 bad_request probe applies to stdio providers only",
222            ));
223            checks.push(CheckResult::skip(
224                CHECK_CORRELATION,
225                "wire-level §H4 id-echo probe applies to stdio providers only",
226            ));
227            checks.push(CheckResult::skip(
228                CHECK_ATTESTATION,
229                "wire-level §6.5 attestation probe applies to stdio providers only",
230            ));
231        }
232    }
233
234    ConformanceReport {
235        target: description,
236        checks,
237    }
238}
239
240/// Stand up a one-provider host for the target and read back the provider's
241/// negotiated identity + capabilities. Records consent for an egress
242/// provider under test — running the suite *is* the consent to its declared
243/// flow, so it isn't spuriously gated.
244async fn build_host(
245    target: ProviderTarget,
246) -> Result<(Host, String, ProviderInfo, Capabilities), HostError> {
247    let mut host = Host::new();
248    let (id, info, caps) = match target {
249        ProviderTarget::Stdio { program, args } => {
250            let id = "provider-under-test".to_string();
251            host.add_stdio(id.clone(), &program, &args).await?;
252            capture_identity(&host, &id)?
253        }
254        ProviderTarget::Http { url } => {
255            let id = "provider-under-test".to_string();
256            host.add_http(id.clone(), url, None).await?;
257            capture_identity(&host, &id)?
258        }
259        ProviderTarget::InProcess(provider) => {
260            let id = provider.id().to_string();
261            let info = provider.info().clone();
262            let caps = provider.capabilities().clone();
263            host.register(provider);
264            (id, info, caps)
265        }
266    };
267
268    // Running the suite *is* consent to the provider's declared flow, so it
269    // isn't spuriously gated. Record the legacy boolean consent, and a receipt
270    // for every off-machine egress scope the provider declares (the scope gate).
271    if info.data_flow.egress {
272        host.record_consent(ConsentRecord::new(
273            id.clone(),
274            info.data_flow.clone(),
275            "conformance run under test",
276        ));
277    }
278    for scope in info.data_flow.off_machine_scopes() {
279        host.record_receipt(ConsentReceipt::new(
280            id.clone(),
281            &info,
282            scope.clone(),
283            Grantor::Policy("conformance-suite".into()),
284            "2026-07-21T00:00:00Z",
285        ));
286    }
287
288    Ok((host, id, info, caps))
289}
290
291fn capture_identity(
292    host: &Host,
293    id: &str,
294) -> Result<(String, ProviderInfo, Capabilities), HostError> {
295    let provider = host
296        .provider(id)
297        .ok_or_else(|| HostError::UnknownProvider(id.to_string()))?;
298    Ok((
299        id.to_string(),
300        provider.info().clone(),
301        provider.capabilities().clone(),
302    ))
303}
304
305async fn run_query_and_shutdown_checks(
306    host: Host,
307    id: &str,
308    caps: &Capabilities,
309    checks: &mut Vec<CheckResult>,
310) {
311    let query = sample_query();
312    match host.query_provider(id, &query).await {
313        Ok(result) => {
314            let (ok, evidence) = check_frames(&result);
315            checks.push(CheckResult::from_bool(CHECK_FRAME_VALIDITY, ok, evidence));
316            checks.push(check_verify_honesty(&host, id, caps, &result).await);
317
318            let (budget_ok, budget_evidence) = check_budget(&result, &query);
319            checks.push(CheckResult::from_bool(
320                CHECK_BUDGET_HONESTY,
321                budget_ok,
322                budget_evidence,
323            ));
324        }
325        Err(error) => {
326            let evidence = format!("query failed: {error}");
327            checks.push(CheckResult::fail(CHECK_FRAME_VALIDITY, evidence.clone()));
328            checks.push(CheckResult::fail(CHECK_VERIFY_HONESTY, evidence.clone()));
329            checks.push(CheckResult::fail(CHECK_BUDGET_HONESTY, evidence));
330        }
331    }
332
333    // The temporal probe fires its own `as_of`-pinned query, so it stands on
334    // its own regardless of how the unpinned query above fared. The §Q1 probe
335    // is independent for the same reason — it narrows `kinds`, which the
336    // unfiltered query above deliberately never does.
337    checks.push(check_as_of(&host, id).await);
338    checks.push(check_kinds_filter(&host, id, caps).await);
339    checks.push(check_anchor_relevance(&host, id, caps).await);
340    checks.push(check_provenance_fixture_consistency(&host, id).await);
341
342    let results = host.shutdown().await;
343    match results.iter().find(|(pid, _)| pid == id) {
344        Some((_, Ok(()))) => checks.push(CheckResult::pass(
345            CHECK_SHUTDOWN,
346            "provider acknowledged shutdown and tore down cleanly",
347        )),
348        Some((_, Err(error))) => checks.push(CheckResult::fail(
349            CHECK_SHUTDOWN,
350            format!("shutdown error: {error}"),
351        )),
352        None => checks.push(CheckResult::fail(
353            CHECK_SHUTDOWN,
354            "provider vanished before shutdown could be attempted",
355        )),
356    }
357}
358
359/// Suffix appended to a real digest to simulate a mutated source. Derived from
360/// the provider's own digest, so it is guaranteed to differ from it while
361/// staying vanishingly unlikely to collide with any digest the provider
362/// actually serves.
363const MUTATED_SUFFIX: &str = "-contextgraph-conformance-mutated";
364
365/// Probe `context/verify` honesty (`docs/context-reuse.md` §4, requirement V1).
366///
367/// A provider's digest is opaque and provider-declared, so only the provider
368/// can say whether an identity still names its current bytes — which means the
369/// suite cannot check the *answer*, only that the provider **distinguishes**.
370/// So it asks twice about frames the provider just served:
371///
372/// 1. with the **real** digests it returned — an honest provider says `valid`;
373/// 2. with those digests **mutated** — from the provider's side this is
374///    indistinguishable from a source that changed underneath the host, and an
375///    honest provider says `stale`.
376///
377/// A provider that rubber-stamps everything `valid` fails the second ask; one
378/// that advertises `verify` but can never vouch for anything fails the first.
379/// Both are caught without the suite needing to mutate a real source.
380///
381/// Skipped — not failed — when the provider does not advertise `verify`: that
382/// is the declared capability-gated fallback (V3), and the host re-queries
383/// instead.
384async fn check_verify_honesty(
385    host: &Host,
386    id: &str,
387    caps: &Capabilities,
388    result: &ContextQueryResult,
389) -> CheckResult {
390    if !caps.verify {
391        return CheckResult::skip(
392            CHECK_VERIFY_HONESTY,
393            "provider does not advertise `verify`; a host falls back to re-querying its frames (§4)",
394        );
395    }
396
397    let held: Vec<FrameId> = result
398        .frames
399        .iter()
400        .filter(|frame| frame.content_digest.is_some())
401        .map(|frame| FrameId::new(id, frame.id.clone(), frame.content_digest.clone()))
402        .collect();
403    if held.is_empty() {
404        return CheckResult::skip(
405            CHECK_VERIFY_HONESTY,
406            "provider served no frame carrying a `content_digest`, so nothing is verifiable (§1 D4)",
407        );
408    }
409
410    // Ask 1: the real digests. Every frame the provider just served must
411    // verify valid — otherwise it cannot vouch for its own output.
412    let unchanged = host.verify_frames(&held).await;
413    if !unchanged.dropped.is_empty() {
414        let detail: Vec<String> = unchanged
415            .dropped
416            .iter()
417            .map(|dropped| format!("{} => {:?}", dropped.frame.frame_id, dropped.reason))
418            .collect();
419        return CheckResult::fail(
420            CHECK_VERIFY_HONESTY,
421            format!(
422                "provider advertises `verify` but did not answer `valid` for {} of {} frame(s) it had just served with unchanged digests: {}",
423                unchanged.dropped.len(),
424                held.len(),
425                detail.join(", ")
426            ),
427        );
428    }
429
430    // Ask 2: the same frames with mutated digests — what a changed source
431    // looks like from the provider's side.
432    let mutated: Vec<FrameId> = held
433        .iter()
434        .map(|frame| {
435            FrameId::new(
436                id,
437                frame.frame_id.clone(),
438                frame
439                    .content_digest
440                    .as_ref()
441                    .map(|digest| format!("{digest}{MUTATED_SUFFIX}")),
442            )
443        })
444        .collect();
445    let changed = host.verify_frames(&mutated).await;
446
447    if !changed.retained.is_empty() {
448        return CheckResult::fail(
449            CHECK_VERIFY_HONESTY,
450            format!(
451                "provider answered `valid` for {} frame(s) whose content digest it never served — a rubber stamp that lets a host cite stale evidence",
452                changed.retained.len()
453            ),
454        );
455    }
456    let not_stale: Vec<String> = changed
457        .dropped
458        .iter()
459        .filter(|dropped| !matches!(dropped.reason, DropReason::Stale { .. }))
460        .map(|dropped| format!("{} => {:?}", dropped.frame.frame_id, dropped.reason))
461        .collect();
462    if !not_stale.is_empty() {
463        return CheckResult::fail(
464            CHECK_VERIFY_HONESTY,
465            format!(
466                "a digest mismatch on a frame the provider still serves MUST verify `stale` (§4 V1); got: {}",
467                not_stale.join(", ")
468            ),
469        );
470    }
471
472    CheckResult::pass(
473        CHECK_VERIFY_HONESTY,
474        format!(
475            "provider verified {n} unchanged frame(s) `valid` and all {n} mutated digest(s) `stale`, carrying no frame bodies",
476            n = held.len()
477        ),
478    )
479}
480
481/// Wire-level probe: complete the handshake on a fresh connection, inject a
482/// malformed line, then send a valid query. A conforming provider either
483/// ignores the garbage and answers the query, or errors on it with code
484/// `bad_request` — and stays alive either way (SPEC.md §R1). A provider that
485/// dies on one bad line fails; so, now, does one that stays alive but reports an
486/// error *other* than `bad_request` — the code is read, not merely the fact of
487/// an error (#9), so the check can tell a well-formed rejection from an
488/// arbitrary failure.
489async fn malformed_stdio_probe(program: &str, args: &[String]) -> CheckResult {
490    let mut conn = match RawStdioConnection::spawn(program, args).await {
491        Ok(conn) => conn,
492        Err(error) => {
493            return CheckResult::fail(
494                CHECK_MALFORMED,
495                format!("could not spawn provider: {error}"),
496            );
497        }
498    };
499    if let Err(error) = conn.handshake().await {
500        return CheckResult::fail(
501            CHECK_MALFORMED,
502            format!("handshake failed before the probe could run: {error}"),
503        );
504    }
505    if let Err(error) = conn.send_raw_line("this is not valid json {{{\n").await {
506        return CheckResult::fail(
507            CHECK_MALFORMED,
508            format!("provider closed its input on a malformed line: {error}"),
509        );
510    }
511    if let Err(error) = conn
512        .send(&contextgraph_host::Envelope::Query {
513            id: None,
514            query: sample_query(),
515        })
516        .await
517    {
518        return CheckResult::fail(
519            CHECK_MALFORMED,
520            format!("provider died after a malformed line (before a valid query): {error}"),
521        );
522    }
523    match conn.recv().await {
524        Ok(contextgraph_host::Envelope::Frames { .. }) => CheckResult::pass(
525            CHECK_MALFORMED,
526            "provider ignored a malformed line and still answered a valid query",
527        ),
528        // §R1's SHOULD: staying alive is the MUST, but a *structured*
529        // `bad_request` is what lets a host tell "your line was malformed" from
530        // an arbitrary failure. Inspecting the code (as the §E1 probe does) is
531        // the whole point of #9 — passing on any error would leave the code
532        // unread and the distinction unmade.
533        Ok(contextgraph_host::Envelope::Error {
534            code: Some(ErrorCode::BadRequest),
535            message,
536            ..
537        }) => CheckResult::pass(
538            CHECK_MALFORMED,
539            format!(
540                "provider errored cleanly on malformed input with `bad_request` and stayed alive: {message}"
541            ),
542        ),
543        // Alive, but the error is not the `bad_request` §R1 recommends (a
544        // different code, or none at all). The MUST is met; the SHOULD is not,
545        // and an unstructured failure is exactly what structured codes exist to
546        // replace — so this is flagged.
547        Ok(contextgraph_host::Envelope::Error { code, message, .. }) => CheckResult::fail(
548            CHECK_MALFORMED,
549            format!(
550                "provider stayed alive but answered malformed input with `{}` rather than the `bad_request` §R1 recommends: {message}",
551                code.map(|c| c.to_string())
552                    .unwrap_or_else(|| "no code".to_string())
553            ),
554        ),
555        Ok(other) => CheckResult::fail(
556            CHECK_MALFORMED,
557            format!(
558                "provider replied to a valid query with an unexpected `{}` envelope",
559                contextgraph_host::envelope_kind(&other)
560            ),
561        ),
562        Err(HostError::ProviderCrashed { .. }) => CheckResult::fail(
563            CHECK_MALFORMED,
564            "provider crashed on a malformed line — it must error-or-ignore, not die",
565        ),
566        Err(error) => CheckResult::fail(
567            CHECK_MALFORMED,
568            format!("provider mishandled malformed input: {error}"),
569        ),
570    }
571}
572
573/// Wire-level probe for §E1: a provider that declares an
574/// `embeddings_fingerprint` **SHOULD** reject a query embedding whose length
575/// contradicts that fingerprint's dimension with `bad_request`, rather than
576/// scoring a vector from a different space into plausible-looking, meaningless
577/// similarity.
578///
579/// Driven on the raw wire like [`malformed_stdio_probe`], because the honest
580/// reply is an `error` envelope carrying a *code* — and the host's query path
581/// collapses that to a bare message, losing the `bad_request` §E1 names. Reading
582/// the code directly is what makes this checkable, and is why the probe is
583/// stdio-only (skipped for in-process/HTTP targets — a documented limitation).
584///
585/// Gated on the provider declaring a fingerprint: one that declares none has no
586/// dimension to contradict and is skipped, exactly as a provider that does not
587/// advertise `verify` skips `verify-honesty`.
588async fn embedding_fingerprint_stdio_probe(program: &str, args: &[String]) -> CheckResult {
589    let mut conn = match RawStdioConnection::spawn(program, args).await {
590        Ok(conn) => conn,
591        Err(error) => {
592            return CheckResult::fail(
593                CHECK_EMBEDDING_FINGERPRINT,
594                format!("could not spawn provider: {error}"),
595            );
596        }
597    };
598    let caps = match conn.handshake().await {
599        Ok((_, caps)) => caps,
600        Err(error) => {
601            return CheckResult::skip(
602                CHECK_EMBEDDING_FINGERPRINT,
603                format!("handshake failed before the §E1 probe could run: {error}"),
604            );
605        }
606    };
607    let Some(fingerprint) = caps.embeddings_fingerprint.clone() else {
608        return CheckResult::skip(
609            CHECK_EMBEDDING_FINGERPRINT,
610            "provider declares no embeddings_fingerprint, so §E1 has no dimension to contradict",
611        );
612    };
613    let Some(dimension) = fingerprint_dimensions(&fingerprint) else {
614        return CheckResult::skip(
615            CHECK_EMBEDDING_FINGERPRINT,
616            format!(
617                "fingerprint `{fingerprint}` declares no parseable dimension, so §E1 cannot be probed"
618            ),
619        );
620    };
621
622    // A length guaranteed to differ from the declared dimension — the
623    // wrong-space vector §E1 says to reject.
624    let wrong_len = if dimension == 1 { 2 } else { 1 };
625    let mut query = sample_query();
626    query.embedding = Some(vec![0.0; wrong_len]);
627    if let Err(error) = conn
628        .send(&contextgraph_host::Envelope::Query { id: None, query })
629        .await
630    {
631        return CheckResult::fail(
632            CHECK_EMBEDDING_FINGERPRINT,
633            format!("provider closed its input before the §E1 probe query: {error}"),
634        );
635    }
636    match conn.recv().await {
637        // The recommended reply: it named the request wrong with the code §E1
638        // specifies.
639        Ok(contextgraph_host::Envelope::Error {
640            code: Some(ErrorCode::BadRequest),
641            ..
642        }) => CheckResult::pass(
643            CHECK_EMBEDDING_FINGERPRINT,
644            format!(
645                "provider declares {fingerprint} ({dimension}-dim) and rejected a {wrong_len}-dim embedding with `bad_request` (§E1)"
646            ),
647        ),
648        // Refused, but not with the code §E1 recommends. Refusing at all is the
649        // load-bearing half of a SHOULD, so this passes with a note.
650        Ok(contextgraph_host::Envelope::Error { code, message, .. }) => CheckResult::pass(
651            CHECK_EMBEDDING_FINGERPRINT,
652            format!(
653                "provider rejected a {wrong_len}-dim embedding against {fingerprint} with `{}` rather than the `bad_request` §E1 recommends: {message}",
654                code.unwrap_or(ErrorCode::Internal)
655            ),
656        ),
657        // The violation: it *scored* a vector from a different space.
658        Ok(contextgraph_host::Envelope::Frames { .. }) => CheckResult::fail(
659            CHECK_EMBEDDING_FINGERPRINT,
660            format!(
661                "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)"
662            ),
663        ),
664        Ok(other) => CheckResult::fail(
665            CHECK_EMBEDDING_FINGERPRINT,
666            format!(
667                "provider answered the §E1 probe with an unexpected `{}` envelope",
668                contextgraph_host::envelope_kind(&other)
669            ),
670        ),
671        Err(HostError::ProviderCrashed { .. }) => CheckResult::fail(
672            CHECK_EMBEDDING_FINGERPRINT,
673            "provider crashed on a dimension-mismatched embedding — §E1 asks it to reply `bad_request`, not die",
674        ),
675        Err(error) => CheckResult::fail(
676            CHECK_EMBEDDING_FINGERPRINT,
677            format!("provider mishandled the §E1 probe: {error}"),
678        ),
679    }
680}
681
682/// The correlation id the §H4 probe sends. Deliberately distinctive so a
683/// provider that echoes *something* — a counter, its own id — fails rather
684/// than coincidentally matching.
685const CORRELATION_PROBE_ID: &str = "cgp-conformance-h4-7f3a";
686
687/// **§H4** — a provider declaring `capabilities.correlation` **MUST** echo a
688/// request's `id` verbatim on the corresponding `frames` or `error`.
689///
690/// This check exists because the guarantee was previously unenforceable from
691/// outside. H4's only witness was the reference provider's
692/// `drop-correlation-id` misbehave mode, and that mode "went red" merely
693/// because dropping the id desynchronizes the host's demultiplexer and breaks
694/// every *other* check downstream. Nothing actually asserted the echo — so an
695/// external implementation (each of the three SDKs) could declare
696/// `correlation: true`, never echo an id, and pass the suite. Requiring the
697/// matching check in `conformance-red.sh` is what surfaced the hole.
698///
699/// The probe is raw-stdio rather than host-driven for the same reason the §E1
700/// probe is: the host layer *interprets* correlation (it demultiplexes on the
701/// id and raises `CorrelationMismatch`), so driving through it would test the
702/// host's reaction rather than the provider's wire behavior.
703async fn correlation_stdio_probe(program: &str, args: &[String]) -> CheckResult {
704    let mut conn = match RawStdioConnection::spawn(program, args).await {
705        Ok(conn) => conn,
706        Err(error) => {
707            return CheckResult::fail(
708                CHECK_CORRELATION,
709                format!("could not spawn provider: {error}"),
710            );
711        }
712    };
713    let caps = match conn.handshake().await {
714        Ok((_, caps)) => caps,
715        Err(error) => {
716            return CheckResult::skip(
717                CHECK_CORRELATION,
718                format!("handshake failed before the §H4 probe could run: {error}"),
719            );
720        }
721    };
722    if !caps.correlation {
723        // Not a failure: correlation is negotiated, and a lock-step provider
724        // that never claims it is conformant. H4 binds only those who declare.
725        return CheckResult::skip(
726            CHECK_CORRELATION,
727            "provider does not declare capabilities.correlation, so §H4 does not bind it",
728        );
729    }
730
731    let query = sample_query();
732    if let Err(error) = conn
733        .send(&contextgraph_host::Envelope::Query {
734            id: Some(CORRELATION_PROBE_ID.to_string()),
735            query,
736        })
737        .await
738    {
739        return CheckResult::fail(
740            CHECK_CORRELATION,
741            format!("provider closed its input before the §H4 probe query: {error}"),
742        );
743    }
744
745    let reply = match conn.recv().await {
746        Ok(reply) => reply,
747        Err(error) => {
748            return CheckResult::fail(
749                CHECK_CORRELATION,
750                format!("provider mishandled the §H4 probe: {error}"),
751            );
752        }
753    };
754
755    let kind = contextgraph_host::envelope_kind(&reply);
756    // `frames` and `error` both answer a query, and H4 binds both.
757    match reply.correlation_id() {
758        Some(echoed) if echoed == CORRELATION_PROBE_ID => CheckResult::pass(
759            CHECK_CORRELATION,
760            format!(
761                "provider declares correlation and echoed the request id verbatim on its `{kind}` reply (§H4)"
762            ),
763        ),
764        Some(echoed) => CheckResult::fail(
765            CHECK_CORRELATION,
766            format!(
767                "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)"
768            ),
769        ),
770        None if matches!(reply, contextgraph_host::Envelope::Frames { .. })
771            || matches!(reply, contextgraph_host::Envelope::Error { .. }) =>
772        {
773            CheckResult::fail(
774                CHECK_CORRELATION,
775                format!(
776                    "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)"
777                ),
778            )
779        }
780        None => CheckResult::fail(
781            CHECK_CORRELATION,
782            format!("provider answered the §H4 probe with an unexpected `{kind}` envelope"),
783        ),
784    }
785}
786
787/// **§6.5 (F6–F9)** — a provenance attestation is checked, never taken on
788/// trust.
789///
790/// A digest proves the bytes have not moved since somebody wrote that number
791/// down. It says nothing about *who* wrote it, and the digest and the frame come
792/// from the same unauthenticated party — so §6.2 is satisfied in full by a
793/// provider that fabricated both. A signature is the only thing that closes
794/// that, and until this check existed the four guarantees over it were verified
795/// by `contextgraph_types::attest`'s own unit tests and by nothing on the wire.
796/// Every other guarantee in this protocol earns its credibility from a suite
797/// with an adversarial mode behind it; a guarantee whose only witness is the
798/// implementation asserting about itself is the self-attestation §11.1 rules
799/// out.
800///
801/// The probe:
802///
803/// 1. reads the [attester keys](contextgraph_host::AttesterKey) the handshake
804///    published — a *construction* anchor, not a trust one (see that type);
805/// 2. queries, and takes the detached attestations off the *result* — their
806///    one wire home (`SPEC.md` §6.5.5, #161);
807/// 3. recomputes each named frame's commitment from the frame in hand and
808///    verifies the signature over it, exactly as §6.5.4 orders the two steps;
809/// 4. when anything fails to verify, re-asks the same provider **through the
810///    reference [`Host`]** and asserts the affected frame still arrives —
811///    F9's rule that an unverifiable attestation degrades a frame to
812///    *unattested* rather than removing it.
813///
814/// **`provider_id` in the commitment is the handshake-declared `provider.name`.**
815/// §6.5.2 binds a commitment to a provider id without saying which one, and the
816/// host-chosen local id (`provider-under-test` here) is not a string the
817/// provider ever sees — so it is not one a provider could sign against. The
818/// declared name is the only identifier both ends observe. Pinning that in
819/// SPEC.md is tracked separately; until it is, an implementation reading only
820/// the spec could pick differently and fail this check for the wrong reason.
821///
822/// Raw-stdio like the §R1, §E1 and §H4 probes, but for a different reason: the
823/// attester keys are read off the handshake, which [`Host`] performs and does
824/// not surface.
825///
826/// Passing outcomes, in order of how a provider reaches them:
827///
828/// - publishes no key and serves no attestation ⇒ **pass**. §6.5 makes the
829///   construction mandatory and the signing optional, so a provider that signs
830///   nothing is conformant and this check has nothing to say about it.
831/// - every attestation it serves verifies ⇒ **pass**.
832/// - every attestation names a scheme this build cannot check ⇒ **skip**, per
833///   F8: "I cannot check this" is never "this is good", and it is equally never
834///   "this is forged".
835async fn attestation_stdio_probe(program: &str, args: &[String]) -> CheckResult {
836    let mut conn = match RawStdioConnection::spawn(program, args).await {
837        Ok(conn) => conn,
838        Err(error) => {
839            return CheckResult::fail(
840                CHECK_ATTESTATION,
841                format!("could not spawn provider: {error}"),
842            );
843        }
844    };
845    let info = match conn.handshake().await {
846        Ok((info, _)) => info,
847        Err(error) => {
848            return CheckResult::skip(
849                CHECK_ATTESTATION,
850                format!("handshake failed before the §6.5 probe could run: {error}"),
851            );
852        }
853    };
854    let keys: Vec<AttesterKey> = conn.attester_keys().to_vec();
855
856    if let Err(error) = conn
857        .send(&contextgraph_host::Envelope::Query {
858            id: None,
859            query: sample_query(),
860        })
861        .await
862    {
863        return CheckResult::fail(
864            CHECK_ATTESTATION,
865            format!("provider closed its input before the §6.5 probe query: {error}"),
866        );
867    }
868    let (frames, attestations, result_attestation) = match conn.recv().await {
869        // §6.5.5: the evidence rides the *result*, and nowhere else (#161).
870        Ok(contextgraph_host::Envelope::Frames { result, .. }) => (
871            result.frames,
872            result.frame_attestations,
873            result.result_attestation,
874        ),
875        Ok(other) => {
876            return CheckResult::fail(
877                CHECK_ATTESTATION,
878                format!(
879                    "provider answered the §6.5 probe with an unexpected `{}` envelope",
880                    contextgraph_host::envelope_kind(&other)
881                ),
882            );
883        }
884        Err(error) => {
885            return CheckResult::fail(
886                CHECK_ATTESTATION,
887                format!("provider mishandled the §6.5 probe: {error}"),
888            );
889        }
890    };
891
892    if keys.is_empty() && attestations.is_empty() {
893        return CheckResult::pass(
894            CHECK_ATTESTATION,
895            "provider publishes no attester key and serves no attestation; §6.5 makes the construction mandatory and the signing optional, so there is nothing here to forge",
896        );
897    }
898    if keys.is_empty() {
899        return CheckResult::fail(
900            CHECK_ATTESTATION,
901            format!(
902                "provider served {} attestation(s) but published no attester key at the handshake — nothing reading this wire can check them, and an unverifiable signature is decoration (§6.5.4)",
903                attestations.len()
904            ),
905        );
906    }
907    if frames.is_empty() {
908        return CheckResult::pass(
909            CHECK_ATTESTATION,
910            "provider returned 0 frames, so there is nothing to attest (permitted — nothing relevant to the probe)",
911        );
912    }
913    if attestations.is_empty() {
914        return CheckResult::fail(
915            CHECK_ATTESTATION,
916            format!(
917                "provider published {} attester key(s) at the handshake but attested none of the {} frame(s) it served — a signing capability nothing can exercise (§6.5)",
918                keys.len(),
919                frames.len()
920            ),
921        );
922    }
923
924    let mut verified: Vec<String> = Vec::new();
925    let mut uncheckable: Vec<String> = Vec::new();
926    let mut problems: Vec<String> = Vec::new();
927    // Frames whose attestation did not verify: F9 says these must still be
928    // served, degraded to unattested rather than dropped.
929    let mut degraded: Vec<String> = Vec::new();
930
931    for entry in &attestations {
932        // §6.5.5 names the frame by its whole `(provider_id, frame_id,
933        // content_digest)` identity, never by a bare id. Matching on the triple
934        // is what stops a provider lending one frame's signature to another
935        // that happens to reuse its id.
936        let named = &entry.frame.frame_id;
937        let Some(frame) = frames
938            .iter()
939            .find(|frame| frame.identity(&info.name) == entry.frame)
940        else {
941            problems.push(format!(
942                "attestation names frame `{named}`, whose identity is not in the answer it rides with (§6.5.2 binds a signature to one frame of one answer, by provider id, frame id and content digest)"
943            ));
944            continue;
945        };
946
947        // An entry carries a per-frame signature, or membership of the signed
948        // result-set root, or both (§6.5.5, F13). The second is the cheapest
949        // honest shape — one signature for n frames — and a probe that only
950        // understood the first would report every provider that chose it as
951        // having signed nothing.
952        let (signature, proof) = match (&entry.attestation, &entry.inclusion_proof) {
953            (Some(attestation), _) => (attestation, None),
954            (None, Some(proof)) => match result_attestation.as_ref() {
955                Some(root) => (root, Some(proof)),
956                None => {
957                    problems.push(format!(
958                        "frame `{named}` is attested only by an inclusion proof, but the answer carries no `result_attestation` for that proof to establish membership of (§6.5.3)"
959                    ));
960                    degraded.push(named.clone());
961                    continue;
962                }
963            },
964            (None, None) => {
965                problems.push(format!(
966                    "the attestation entry for frame `{named}` carries neither a signature nor an inclusion proof, so it names a frame and asserts nothing about it (§6.5.5)"
967                ));
968                continue;
969            }
970        };
971
972        let Some(key) = keys.iter().find(|key| key.key_id == signature.key_id) else {
973            problems.push(format!(
974                "frame `{named}` is signed under key_id `{}`, which the handshake never published",
975                signature.key_id
976            ));
977            continue;
978        };
979        let Some(key_bytes) = decode_hex(&key.public_key) else {
980            problems.push(format!(
981                "published key `{}` is not lowercase hex, so no verifier can load it",
982                key.key_id
983            ));
984            continue;
985        };
986
987        let verdict = match proof {
988            Some(proof) => verify_frame_inclusion(&info.name, frame, proof, signature, &key_bytes),
989            None => verify_frame_attestation(&info.name, frame, signature, &key_bytes),
990        };
991
992        match verdict {
993            AttestationVerdict::Valid => verified.push(named.clone()),
994            // The signature checks out over a preimage that binds identity and
995            // provenance but says nothing about the frame's bytes, because the
996            // frame declared no `content_digest` (#128). `SPEC.md` §6.5.2
997            // requires a provider that signs a frame to populate one, so this
998            // is a conformance failure of the *attester*, not an unverifiable
999            // attestation.
1000            //
1001            // It is a problem rather than a degradation for that reason. The
1002            // frame is servable and the signature is real; what is wrong is the
1003            // claim a reader would take from it — a provider offering this is
1004            // publishing a signature that outlives the content it appears to
1005            // cover, and can re-serve different bytes under the same id
1006            // tomorrow without disturbing it. Saying that here is the only
1007            // place a provider author finds out before a consumer does.
1008            AttestationVerdict::ValidIdentityOnly => {
1009                problems.push(format!(
1010                    "frame `{named}` is signed but declares no `content_digest`, so the \
1011                     signature binds its identity and provenance and nothing about its \
1012                     content — the same id can be re-served with different bytes and \
1013                     this signature still verifies. SPEC.md §6.5.2 requires an attester \
1014                     to populate `content_digest` on any frame it signs"
1015                ));
1016                degraded.push(named.clone());
1017            }
1018            AttestationVerdict::UnknownAlgorithm(algorithm) => {
1019                uncheckable.push(format!("{named} (algorithm `{algorithm}`)"));
1020                degraded.push(named.clone());
1021            }
1022            verdict => {
1023                problems.push(describe_attestation_failure(named, &verdict));
1024                degraded.push(named.clone());
1025            }
1026        }
1027    }
1028
1029    // F9. An unverifiable attestation degrades its frame to *unattested*; it
1030    // never removes the frame from the answer, because a host that dropped such
1031    // frames would hand any peer a denial-of-service primitive — attach garbage,
1032    // watch the evidence disappear. Asked of the reference host rather than of
1033    // this probe's own bookkeeping, so it is a claim about a host's behaviour
1034    // and not about the suite's.
1035    if !degraded.is_empty()
1036        && let Some(dropped) = frames_the_host_dropped(program, args, &degraded).await
1037    {
1038        problems.push(format!(
1039            "the host stopped serving {} after their attestation failed to verify — F9 requires an unverifiable attestation to degrade a frame to unattested, never to remove it",
1040            dropped.join(", ")
1041        ));
1042    }
1043
1044    if !problems.is_empty() {
1045        return CheckResult::fail(
1046            CHECK_ATTESTATION,
1047            format!(
1048                "{} of {} attestation(s) did not verify (§6.5.4): {}",
1049                problems.len(),
1050                attestations.len(),
1051                problems.join("; ")
1052            ),
1053        );
1054    }
1055    if verified.is_empty() {
1056        return CheckResult::skip(
1057            CHECK_ATTESTATION,
1058            format!(
1059                "every attestation names a scheme this build cannot check, so F8 declines rather than guessing: {}",
1060                uncheckable.join(", ")
1061            ),
1062        );
1063    }
1064
1065    let note = if uncheckable.is_empty() {
1066        String::new()
1067    } else {
1068        format!(
1069            "; {} left unattested by F8 as uncheckable here: {}",
1070            uncheckable.len(),
1071            uncheckable.join(", ")
1072        )
1073    };
1074    CheckResult::pass(
1075        CHECK_ATTESTATION,
1076        format!(
1077            "recomputed and verified {} detached attestation(s) over {} served frame(s) against the handshake-published key(s) (§6.5){note}",
1078            verified.len(),
1079            frames.len()
1080        ),
1081    )
1082}
1083
1084/// Name an [`AttestationVerdict`] failure in the terms §6.5.4 separates them
1085/// into, because the two loudest ones call for opposite responses: a mismatch
1086/// says the frame moved after signing (tampering), a bad signature says the key
1087/// is wrong or the signature forged (key management).
1088fn describe_attestation_failure(frame_id: &str, verdict: &AttestationVerdict) -> String {
1089    match verdict {
1090        AttestationVerdict::CommitmentMismatch { expected, signed } => format!(
1091            "frame `{frame_id}` recomputes to {expected} but its attestation signs {signed} — the frame, its `content_digest`, or its provenance chain changed after signing (CommitmentMismatch)"
1092        ),
1093        AttestationVerdict::BadSignature => format!(
1094            "frame `{frame_id}` commits correctly but its signature does not verify under the published key — forged, or signed by a key the provider did not declare (BadSignature)"
1095        ),
1096        AttestationVerdict::MalformedKey => {
1097            format!("frame `{frame_id}`: the published key is not a well-formed key (MalformedKey)")
1098        }
1099        AttestationVerdict::MalformedSignature => format!(
1100            "frame `{frame_id}`: the signature field is not well-formed for its algorithm (MalformedSignature)"
1101        ),
1102        AttestationVerdict::MalformedCommitment => format!(
1103            "frame `{frame_id}`: `signed_commitment` is not the `sha256:<64 lowercase hex>` §F7 requires (MalformedCommitment)"
1104        ),
1105        // Handled by the caller, which reports it as uncheckable rather than
1106        // as a failure — F8's whole point.
1107        other => format!("frame `{frame_id}`: {other:?}"),
1108    }
1109}
1110
1111/// Of `expected` frame ids, those the reference [`Host`] does **not** deliver —
1112/// F9's question, asked of a host rather than of this probe.
1113///
1114/// `None` when the question could not be put (the host could not be stood up,
1115/// or the query failed): an unanswerable question is not evidence of a
1116/// violation, and reporting it as one would fail a provider for the suite's own
1117/// trouble.
1118async fn frames_the_host_dropped(
1119    program: &str,
1120    args: &[String],
1121    expected: &[String],
1122) -> Option<Vec<String>> {
1123    let mut host = Host::new();
1124    let id = "f9-probe".to_string();
1125    host.add_stdio(id.clone(), program, args).await.ok()?;
1126    let result = host.query_provider(&id, &sample_query()).await.ok()?;
1127    let served: Vec<&str> = result
1128        .frames
1129        .iter()
1130        .map(|frame| frame.id.as_str())
1131        .collect();
1132    let _ = host.shutdown().await;
1133    let missing: Vec<String> = expected
1134        .iter()
1135        .filter(|id| !served.contains(&id.as_str()))
1136        .cloned()
1137        .collect();
1138    (!missing.is_empty()).then_some(missing)
1139}
1140
1141/// Decode lowercase hex into bytes. `None` on an odd length or a non-hex digit
1142/// — a published key that cannot be decoded is a provider defect, not a
1143/// verification failure, and the two are reported differently.
1144fn decode_hex(hex: &str) -> Option<Vec<u8>> {
1145    if !hex.len().is_multiple_of(2) {
1146        return None;
1147    }
1148    // The even-length check above leaves no remainder, so `.0` drops nothing.
1149    // `as_chunks` yields `&[u8; 2]`, which indexes without a bounds check.
1150    hex.as_bytes()
1151        .as_chunks::<2>()
1152        .0
1153        .iter()
1154        .map(|pair| {
1155            let hi = (pair[0] as char).to_digit(16)?;
1156            let lo = (pair[1] as char).to_digit(16)?;
1157            Some((hi * 16 + lo) as u8)
1158        })
1159        .collect()
1160}
1161
1162/// The instant the `as_of` probe pins retrieval to (`SPEC.md` §6.1). Chosen to
1163/// fall *between* the reference fixture's two frame validity windows, so an
1164/// honest provider's pinned answer is observably narrower than its unpinned one.
1165const AS_OF_PIN: &str = "2026-07-01T00:00:00Z";
1166
1167/// Probe `as_of` temporal pinning (`SPEC.md` §6.1, §F4). `as_of` pins retrieval
1168/// to an instant; a frame whose `valid_from` is strictly after the pin is
1169/// content that was not yet true then — exactly what the pin exists to keep out
1170/// of the answer.
1171///
1172/// SHOULD-strength and deliberately one-sided: it never penalizes a provider for
1173/// returning *fewer* frames (or none) under a pin, because implementing
1174/// time-travel retrieval is optional. It fails only on a frame the provider
1175/// *did* return whose `valid_from` provably postdates the pin — a temporal lie
1176/// no matter how sophisticated the provider's time handling. Comparison is
1177/// lexicographic on the UTC strings, which is chronological because the
1178/// timestamp profile admits one spelling per instant (§6.1). A provider serving
1179/// no timestamped content trivially passes.
1180async fn check_as_of(host: &Host, id: &str) -> CheckResult {
1181    match host.query_provider(id, &as_of_query()).await {
1182        Ok(result) => {
1183            let not_yet_valid: Vec<String> = result
1184                .frames
1185                .iter()
1186                .filter_map(|frame| {
1187                    frame
1188                        .valid_from
1189                        .as_deref()
1190                        .filter(|valid_from| *valid_from > AS_OF_PIN)
1191                        .map(|valid_from| format!("{} (valid_from={valid_from})", frame.id))
1192                })
1193                .collect();
1194            if not_yet_valid.is_empty() {
1195                CheckResult::pass(
1196                    CHECK_AS_OF,
1197                    format!(
1198                        "as_of={AS_OF_PIN}: none of the {} returned frame(s) is dated after the pin",
1199                        result.frames.len()
1200                    ),
1201                )
1202            } else {
1203                CheckResult::fail(
1204                    CHECK_AS_OF,
1205                    format!(
1206                        "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): {}",
1207                        not_yet_valid.len(),
1208                        not_yet_valid.join(", ")
1209                    ),
1210                )
1211            }
1212        }
1213        Err(error) => CheckResult::fail(CHECK_AS_OF, format!("as_of query failed: {error}")),
1214    }
1215}
1216
1217/// **§Q1** — a non-empty `kinds` is a filter a provider must honor.
1218///
1219/// The probe narrows to a single kind drawn from the provider's *own* declared
1220/// `capabilities.query.kinds`, so it can never be an unfair request: the
1221/// provider said it serves this kind. Every returned frame must then be of that
1222/// kind.
1223///
1224/// Worth stating why this check did not exist until now: [`sample_query`] sends
1225/// `kinds: []`, so every provider was only ever asked the unfiltered question,
1226/// and a provider that ignored the filter entirely passed the whole suite. All
1227/// four reference implementations did exactly that.
1228async fn check_kinds_filter(host: &Host, id: &str, caps: &Capabilities) -> CheckResult {
1229    let Some(declared) = caps.query.kinds.first() else {
1230        return CheckResult::skip(
1231            CHECK_KINDS_FILTER,
1232            "provider declares no query kinds, so §Q1 has no kind to narrow to",
1233        );
1234    };
1235    let Some(kind) = frame_kind_from_wire(declared) else {
1236        return CheckResult::skip(
1237            CHECK_KINDS_FILTER,
1238            format!(
1239                "provider declares kind `{declared}`, which is outside the base FrameKind vocabulary, so §Q1 cannot be probed"
1240            ),
1241        );
1242    };
1243
1244    let query = ContextQuery {
1245        kinds: vec![kind.clone()],
1246        ..sample_query()
1247    };
1248    match host.query_provider(id, &query).await {
1249        Ok(result) => {
1250            let off_kind: Vec<String> = result
1251                .frames
1252                .iter()
1253                .filter(|frame| frame.kind != kind)
1254                .map(|frame| format!("{} (kind={})", frame.id, frame_kind_name(&frame.kind)))
1255                .collect();
1256            if off_kind.is_empty() {
1257                CheckResult::pass(
1258                    CHECK_KINDS_FILTER,
1259                    format!(
1260                        "kinds=[{declared}]: all {} returned frame(s) are of the requested kind (§Q1)",
1261                        result.frames.len()
1262                    ),
1263                )
1264            } else {
1265                CheckResult::fail(
1266                    CHECK_KINDS_FILTER,
1267                    format!(
1268                        "provider returned {} frame(s) outside the requested kinds=[{declared}] — content the host explicitly excluded, charged against its budget (§Q1): {}",
1269                        off_kind.len(),
1270                        off_kind.join(", ")
1271                    ),
1272                )
1273            }
1274        }
1275        Err(error) => CheckResult::fail(
1276            CHECK_KINDS_FILTER,
1277            format!("kinds-filtered query failed: {error}"),
1278        ),
1279    }
1280}
1281
1282/// Parse a declared capability kind string back into the **base** [`FrameKind`]
1283/// vocabulary. `None` for anything outside it — a provider may declare an
1284/// extension kind, and §Q1 simply has nothing to say about it.
1285///
1286/// [`FrameKind::from_wire`] never fails: since the kind vocabulary was opened
1287/// for forward compatibility, an unrecognized string parses to
1288/// [`FrameKind::Unknown`] rather than erroring. This check wants the narrower
1289/// question — "is this one of the seven kinds §Q1 is written about?" — so it
1290/// parses and then filters on [`FrameKind::is_known`]. Restating the seven
1291/// names here would put the vocabulary in a second place and let the two drift.
1292fn frame_kind_from_wire(kind: &str) -> Option<FrameKind> {
1293    let parsed = FrameKind::from_wire(kind);
1294    parsed.is_known().then_some(parsed)
1295}
1296
1297/// **§G3/§G4** — a graph-declaring provider must actually do something with
1298/// `anchors`.
1299///
1300/// The graph is what the protocol is *named* for, and it was the least
1301/// exercised surface in the repo: the reference fixture declared
1302/// `graph: false` and served frames with `relations: vec![]`, so G1 and G2
1303/// passed vacuously (no edges to validate) and G3's boost was never witnessed
1304/// at all.
1305///
1306/// The probe first asks an unanchored question to discover a URI the provider
1307/// actually serves, then re-asks anchored on it. Discovering the anchor from
1308/// the provider's own output is what keeps this fair: the suite never invents a
1309/// URI and demands the provider know it.
1310async fn check_anchor_relevance(host: &Host, id: &str, caps: &Capabilities) -> CheckResult {
1311    if !caps.graph {
1312        return CheckResult::skip(
1313            CHECK_ANCHOR_RELEVANCE,
1314            "provider does not declare capabilities.graph, so §G3/§G4 do not bind it",
1315        );
1316    }
1317
1318    let baseline = match host.query_provider(id, &sample_query()).await {
1319        Ok(result) => result,
1320        Err(error) => {
1321            return CheckResult::fail(
1322                CHECK_ANCHOR_RELEVANCE,
1323                format!("baseline query failed: {error}"),
1324            );
1325        }
1326    };
1327
1328    // Prefer a one-hop anchor (a relation target): it proves the provider
1329    // traverses edges, not merely compares its own `uri`.
1330    let anchor = baseline
1331        .frames
1332        .iter()
1333        .find_map(|frame| frame.relations.first().map(|r| r.target_uri.clone()))
1334        .or_else(|| baseline.frames.iter().find_map(|frame| frame.uri.clone()));
1335    let Some(anchor) = anchor else {
1336        return CheckResult::skip(
1337            CHECK_ANCHOR_RELEVANCE,
1338            "provider declares graph but served no frame carrying a uri or a relation target to anchor on",
1339        );
1340    };
1341
1342    let anchored_query = ContextQuery {
1343        anchors: vec![anchor.clone()],
1344        ..sample_query()
1345    };
1346    match host.query_provider(id, &anchored_query).await {
1347        Ok(result) => {
1348            let anchored: Vec<&contextgraph_types::ContextFrame> = result
1349                .frames
1350                .iter()
1351                .filter(|frame| frame_is_anchored(frame, &anchor))
1352                .collect();
1353            if anchored.is_empty() {
1354                return CheckResult::fail(
1355                    CHECK_ANCHOR_RELEVANCE,
1356                    format!(
1357                        "provider declares capabilities.graph but returned no frame anchored on `{anchor}` — a URI drawn from its own previous answer (§G4)"
1358                    ),
1359                );
1360            }
1361            // G3 is a SHOULD, so ranking is reported rather than enforced: a
1362            // provider that finds the anchored frame but orders it second is
1363            // still conformant, and saying so is more honest than inventing a
1364            // MUST the spec does not state.
1365            let first_is_anchored = result
1366                .frames
1367                .first()
1368                .is_some_and(|frame| frame_is_anchored(frame, &anchor));
1369            let ranking = if first_is_anchored {
1370                "and ranked it first"
1371            } else {
1372                "though it did not rank it first (§G3 is a SHOULD)"
1373            };
1374            CheckResult::pass(
1375                CHECK_ANCHOR_RELEVANCE,
1376                format!(
1377                    "anchored on `{anchor}`: provider returned {} anchored frame(s) {ranking}",
1378                    anchored.len()
1379                ),
1380            )
1381        }
1382        Err(error) => CheckResult::fail(
1383            CHECK_ANCHOR_RELEVANCE,
1384            format!("anchored query failed: {error}"),
1385        ),
1386    }
1387}
1388
1389/// §G4's anchoring predicate: the frame's own `uri` (zero hops) or any labelled
1390/// edge's `target_uri` (one hop) equals the anchor.
1391fn frame_is_anchored(frame: &contextgraph_types::ContextFrame, anchor: &str) -> bool {
1392    frame.uri.as_deref() == Some(anchor) || frame.relations.iter().any(|r| r.target_uri == anchor)
1393}
1394
1395/// **§6.2/§F5 (bytes)** — every `file` provenance digest a provider serves must
1396/// match the bytes on disk it names.
1397///
1398/// The `frame-validity` §F5 check proves a provenance digest is *shaped* like a
1399/// sha256; only re-reading the file it addresses proves it is the *right* one.
1400/// This check re-reads each `file` provenance the provider serves and re-hashes
1401/// it with [`contextgraph_host::verify_file_provenance`], the host's own
1402/// byte-level verifier.
1403///
1404/// A definitive failure is a **`Mismatch`**: the bytes are here and hash to
1405/// something else — provenance forgery, or a fixture that drifted out of sync
1406/// with its own files. An **`Unreadable`** link (a `file://` this host cannot
1407/// see — an out-of-tree or remote provider) is *not* a failure: byte
1408/// verification is a host-local capability, and a provider is not broken because
1409/// its files do not sit on this machine. A provider serving no locally-readable
1410/// file provenance is therefore skipped, not failed — mirroring how
1411/// `verify-honesty` skips a provider that does not advertise `verify`.
1412async fn check_provenance_fixture_consistency(host: &Host, id: &str) -> CheckResult {
1413    let result = match host.query_provider(id, &sample_query()).await {
1414        Ok(result) => result,
1415        Err(error) => {
1416            return CheckResult::fail(
1417                CHECK_PROVENANCE_FIXTURE_CONSISTENCY,
1418                format!("query failed: {error}"),
1419            );
1420        }
1421    };
1422
1423    let mut verified = 0usize;
1424    let mut unreadable = 0usize;
1425    let mut mismatches = Vec::new();
1426    for frame in &result.frames {
1427        for (index, outcome) in verify_file_provenance(frame) {
1428            match outcome {
1429                DigestVerification::Verified => verified += 1,
1430                DigestVerification::Mismatch { expected, actual } => mismatches.push(format!(
1431                    "{} provenance[{index}] declared {expected} but its bytes hash to {actual}",
1432                    frame.id
1433                )),
1434                DigestVerification::Unreadable { .. } => unreadable += 1,
1435                DigestVerification::NotFileProvenance => {}
1436            }
1437        }
1438    }
1439
1440    if !mismatches.is_empty() {
1441        return CheckResult::fail(
1442            CHECK_PROVENANCE_FIXTURE_CONSISTENCY,
1443            format!(
1444                "{} 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): {}",
1445                mismatches.len(),
1446                mismatches.join("; ")
1447            ),
1448        );
1449    }
1450    if verified == 0 {
1451        return CheckResult::skip(
1452            CHECK_PROVENANCE_FIXTURE_CONSISTENCY,
1453            format!(
1454                "no locally re-readable file provenance to verify ({unreadable} link(s) name files this host cannot see); §6.2 byte-verification is host-local"
1455            ),
1456        );
1457    }
1458    CheckResult::pass(
1459        CHECK_PROVENANCE_FIXTURE_CONSISTENCY,
1460        format!(
1461            "re-read and re-hashed {verified} file-provenance digest(s) against the bytes on disk — all match (§6.2)"
1462        ),
1463    )
1464}
1465
1466/// The [`sample_query`] pinned to [`AS_OF_PIN`] — the query the temporal probe
1467/// fires. Everything else is held equal so only the pin varies.
1468fn as_of_query() -> ContextQuery {
1469    ContextQuery {
1470        as_of: Some(AS_OF_PIN.into()),
1471        ..sample_query()
1472    }
1473}
1474
1475/// The query the suite probes every provider with — no `kinds` filter, so any
1476/// provider is asked for its best frames (SPEC.md §5).
1477pub fn sample_query() -> ContextQuery {
1478    ContextQuery {
1479        goal: "conformance probe: return your most relevant frames".into(),
1480        query_text: Some("conformance probe".into()),
1481        embedding: None,
1482        kinds: vec![],
1483        anchors: vec![],
1484        max_frames: 8,
1485        max_tokens: 4096,
1486        as_of: None,
1487        representation_preferences: vec![],
1488    }
1489}
1490
1491/// Validate a query result's frames against the `ContextFrame` contract
1492/// (SPEC.md §6). Returns `(passed, evidence)`. Zero frames is permitted — a
1493/// provider may simply have nothing relevant.
1494pub fn check_frames(result: &ContextQueryResult) -> (bool, String) {
1495    if result.frames.is_empty() {
1496        return (
1497            true,
1498            "provider returned 0 frames (permitted — nothing relevant to the probe)".into(),
1499        );
1500    }
1501
1502    let mut problems = Vec::new();
1503    for (i, frame) in result.frames.iter().enumerate() {
1504        if !frame.has_valid_score() {
1505            problems.push(format!("frame[{i}] score {} is outside [0,1]", frame.score));
1506        }
1507        if frame.title.trim().is_empty() {
1508            problems.push(format!("frame[{i}] has an empty title"));
1509        }
1510        match &frame.citation_label {
1511            Some(label) if !label.trim().is_empty() => {}
1512            _ => problems.push(format!(
1513                "frame[{i}] is missing a citation_label (§F3 — never a bare id)"
1514            )),
1515        }
1516        // §P1–P3: a frame must not lie about how it carries its content — a
1517        // `reference` carrying inline content, a `compact` missing its
1518        // canonical hash. `representation_invariants` names the exact breach.
1519        // The predicate shipped in PR #42 with no caller; this is the caller.
1520        if let Err(violation) = frame.representation_invariants() {
1521            problems.push(format!("frame[{i}] {violation} (§P1–P3)"));
1522        }
1523        // §F4: temporal fields must be in the protocol's timestamp profile.
1524        // Naming the offending field is what makes this actionable — before
1525        // this check, `"valid_from": "last tuesday"` was fully conformant and
1526        // the bi-temporal guarantee was unfalsifiable.
1527        for field in frame.invalid_temporal_fields() {
1528            problems.push(format!(
1529                "frame[{i}] field `{field}` is not an RFC 3339 UTC timestamp (§F4)"
1530            ));
1531        }
1532        // §D1: the frame's own content_digest, when present, must be in the
1533        // protocol's digest form. Like §G2 this was listed as verified here and
1534        // read by nothing — so the digest that anchors deterministic
1535        // composition, usage reports and `context/verify` was held to a looser
1536        // standard than the §F5 provenance digests immediately below it.
1537        if !frame.has_usable_content_digest() {
1538            problems.push(format!(
1539                "frame[{i}] content_digest is present but not `sha256:<64 lowercase hex>` (§D1)"
1540            ));
1541        }
1542        // §F5: file provenance must carry a well-formed digest, since that is
1543        // the only provenance a host can independently re-read and verify.
1544        for index in frame.provenance_with_unusable_digests() {
1545            problems.push(format!(
1546                "frame[{i}] provenance[{index}] addresses a file but its digest is missing or not `sha256:<64 lowercase hex>` (§F5)"
1547            ));
1548        }
1549        // §G1/§G2: a graph edge must be citable by a human label, and must
1550        // actually point somewhere. G2 was listed as "Verified by
1551        // frame-validity" while no code read `target_uri` at all — the exact
1552        // self-attestation §11.1 rejects. It is verified here now.
1553        for (edge_index, edge) in frame.relations.iter().enumerate() {
1554            if !edge.has_display_name() {
1555                problems.push(format!(
1556                    "frame[{i}] relation[{edge_index}] `{}` has no display_name (§G1 — an edge is surfaced by label, never a raw id)",
1557                    edge.rel
1558                ));
1559            }
1560            if !edge.has_target_uri() {
1561                problems.push(format!(
1562                    "frame[{i}] relation[{edge_index}] `{}` has an empty target_uri (§G2 — an edge to nowhere is not an edge)",
1563                    edge.rel
1564                ));
1565            }
1566        }
1567    }
1568
1569    if problems.is_empty() {
1570        (
1571            true,
1572            format!(
1573                "{} frame(s) — scores in [0,1], titles, citation labels, honest representations, RFC 3339 timestamps, well-formed digests, labelled and targeted relations",
1574                result.frames.len()
1575            ),
1576        )
1577    } else {
1578        (false, problems.join("; "))
1579    }
1580}
1581
1582/// Validate a query result against the budget contract (`SPEC.md` §B1, §B3,
1583/// §B4). Returns `(passed, evidence)`.
1584///
1585/// Three distinct promises, deliberately checked separately so a failure says
1586/// which one broke:
1587///
1588/// - **§B1** the declared costs sum within `max_tokens`;
1589/// - **§B3** each declared cost equals the canonical count for its content —
1590///   this is what turned the check from arithmetic into truth;
1591/// - **§B4** the frame count respects `max_frames`.
1592pub fn check_budget(result: &ContextQueryResult, query: &ContextQuery) -> (bool, String) {
1593    let mut problems = Vec::new();
1594
1595    let declared = result.total_token_cost();
1596    if declared > query.max_tokens as u64 {
1597        problems.push(format!(
1598            "declared cost {declared} exceeds the query budget of {} (§B1)",
1599            query.max_tokens
1600        ));
1601    }
1602
1603    let dishonest = result.frames_with_dishonest_cost();
1604    if !dishonest.is_empty() {
1605        let canonical = result.canonical_token_cost();
1606        problems.push(format!(
1607            "{} frame(s) misdeclare token_cost — {} (§B3); declared total {declared}, canonical total {canonical}",
1608            dishonest.len(),
1609            dishonest.join(", ")
1610        ));
1611    }
1612
1613    if !result.respects_frame_limit(query.max_frames) {
1614        problems.push(format!(
1615            "returned {} frames against max_frames={} (§B4)",
1616            result.frames.len(),
1617            query.max_frames
1618        ));
1619    }
1620
1621    if problems.is_empty() {
1622        (
1623            true,
1624            format!(
1625                "{} frame(s), {declared} tokens within the {} budget; every declared cost matches its canonical count",
1626                result.frames.len(),
1627                query.max_tokens
1628            ),
1629        )
1630    } else {
1631        (false, problems.join("; "))
1632    }
1633}
1634
1635/// Validate a provider's declared egress scopes against its `data_flow`
1636/// (`docs/context-reuse.md` §3, requirement C5): every scope must be well-formed
1637/// (custom scopes namespaced), and no off-machine scope may be declared with
1638/// `egress: false`. A scope-lying provider — one claiming local posture while
1639/// naming a destination that leaves — fails here.
1640fn check_consent_scopes(info: &ProviderInfo) -> CheckResult {
1641    if info.data_flow.scopes_consistent() {
1642        let scopes: Vec<&str> = info
1643            .data_flow
1644            .egress_scopes
1645            .iter()
1646            .map(|scope| scope.as_str())
1647            .collect();
1648        CheckResult::pass(
1649            CHECK_CONSENT_SCOPE,
1650            format!(
1651                "declared egress scopes {scopes:?} are well-formed and consistent with egress={}",
1652                info.data_flow.egress
1653            ),
1654        )
1655    } else {
1656        CheckResult::fail(
1657            CHECK_CONSENT_SCOPE,
1658            format!(
1659                "egress scopes {:?} are inconsistent with egress={}: an off-machine scope alongside egress=false, or a non-namespaced custom scope (§3, C5)",
1660                info.data_flow
1661                    .egress_scopes
1662                    .iter()
1663                    .map(|scope| scope.as_str())
1664                    .collect::<Vec<_>>(),
1665                info.data_flow.egress
1666            ),
1667        )
1668    }
1669}
1670
1671fn describe_handshake(info: &ProviderInfo, caps: &Capabilities) -> String {
1672    format!(
1673        "provider '{}' v{} — data-flow reads={} writes={} egress={}; query kinds={:?}, graph={}",
1674        info.name,
1675        info.version,
1676        info.data_flow.reads,
1677        info.data_flow.writes,
1678        info.data_flow.egress,
1679        caps.query.kinds,
1680        caps.graph,
1681    )
1682}