1use 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
108pub 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
124pub enum ProviderTarget {
127 Stdio { program: String, args: Vec<String> },
129 Http { url: String },
131 InProcess(Box<dyn ContextProvider>),
133}
134
135impl ProviderTarget {
136 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
152pub async fn run_conformance(target: ProviderTarget) -> ConformanceReport {
156 let description = target.describe();
157
158 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
240async 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 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 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
359const MUTATED_SUFFIX: &str = "-contextgraph-conformance-mutated";
364
365async 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 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 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
481async 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 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 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
573async 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 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 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 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 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
682const CORRELATION_PROBE_ID: &str = "cgp-conformance-h4-7f3a";
686
687async 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 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 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
787async 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 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 let mut degraded: Vec<String> = Vec::new();
930
931 for entry in &attestations {
932 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 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 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 if !degraded.is_empty()
1036 && let Some(dropped) = frames_the_host_dropped(program, args, °raded).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
1084fn 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 other => format!("frame `{frame_id}`: {other:?}"),
1108 }
1109}
1110
1111async 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
1141fn decode_hex(hex: &str) -> Option<Vec<u8>> {
1145 if !hex.len().is_multiple_of(2) {
1146 return None;
1147 }
1148 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
1162const AS_OF_PIN: &str = "2026-07-01T00:00:00Z";
1166
1167async 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
1217async 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
1282fn frame_kind_from_wire(kind: &str) -> Option<FrameKind> {
1293 let parsed = FrameKind::from_wire(kind);
1294 parsed.is_known().then_some(parsed)
1295}
1296
1297async 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 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 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
1389fn 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
1395async 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
1466fn as_of_query() -> ContextQuery {
1469 ContextQuery {
1470 as_of: Some(AS_OF_PIN.into()),
1471 ..sample_query()
1472 }
1473}
1474
1475pub 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
1491pub 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 if let Err(violation) = frame.representation_invariants() {
1521 problems.push(format!("frame[{i}] {violation} (§P1–P3)"));
1522 }
1523 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 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 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 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
1582pub 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
1635fn 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}