1use std::collections::HashMap;
17use std::time::Duration;
18
19use contextgraph_types::{
20 ConsentReceipt, ContextFrame, ContextQuery, ContextQueryResult, DataFlow, EgressScope, FrameId,
21 ProviderUsage, ServedFrame, UsageReport, Verdict, VerifyRequest,
22};
23
24use crate::consent::{ConsentDecision, ConsentRecord, ConsentStore};
25use crate::error::HostError;
26use crate::provider::{ContextProvider, capability_matches};
27use crate::stdio::StdioProvider;
28
29const DEFAULT_PROVIDER_TIMEOUT: Duration = Duration::from_secs(30);
33
34pub struct Host {
37 providers: Vec<Box<dyn ContextProvider>>,
38 consent: ConsentStore,
39 per_provider_timeout: Duration,
40}
41
42impl Default for Host {
43 fn default() -> Self {
44 Self::new()
45 }
46}
47
48impl Host {
49 pub fn new() -> Self {
51 Self {
52 providers: Vec::new(),
53 consent: ConsentStore::new(),
54 per_provider_timeout: DEFAULT_PROVIDER_TIMEOUT,
55 }
56 }
57
58 pub fn with_timeout(per_provider_timeout: Duration) -> Self {
60 Self {
61 per_provider_timeout,
62 ..Self::new()
63 }
64 }
65
66 pub fn register(&mut self, provider: Box<dyn ContextProvider>) {
68 self.providers.push(provider);
69 }
70
71 pub async fn add_stdio(
74 &mut self,
75 id: impl Into<String>,
76 program: &str,
77 args: &[String],
78 ) -> Result<(), HostError> {
79 let provider = StdioProvider::spawn(id, program, args).await?;
80 self.providers.push(Box::new(provider));
81 Ok(())
82 }
83
84 pub async fn add_http(
92 &mut self,
93 id: impl Into<String>,
94 url: impl Into<String>,
95 credential: Option<crate::http::Credential>,
96 ) -> Result<(), HostError> {
97 let provider = crate::http::HttpProvider::connect_with_auth(id, url, credential).await?;
98 self.providers.push(Box::new(provider));
99 Ok(())
100 }
101
102 pub fn record_consent(&mut self, record: ConsentRecord) {
105 self.consent.record(record);
106 }
107
108 pub fn record_receipt(&mut self, receipt: ConsentReceipt) {
113 self.consent.record_receipt(receipt);
114 }
115
116 pub fn consent(&self) -> &ConsentStore {
118 &self.consent
119 }
120
121 pub fn provider_ids(&self) -> Vec<&str> {
123 self.providers.iter().map(|p| p.id()).collect()
124 }
125
126 pub fn provider(&self, id: &str) -> Option<&dyn ContextProvider> {
129 self.providers
130 .iter()
131 .find(|p| p.id() == id)
132 .map(|p| p.as_ref())
133 }
134
135 pub fn len(&self) -> usize {
137 self.providers.len()
138 }
139
140 pub fn is_empty(&self) -> bool {
141 self.providers.is_empty()
142 }
143
144 pub async fn verify_frames(&self, held: &[FrameId]) -> VerifyOutcome {
161 use futures_util::future::join_all;
162
163 let mut order: Vec<&str> = Vec::new();
166 let mut grouped: HashMap<&str, Vec<FrameId>> = HashMap::new();
167 for frame in held {
168 let id = frame.provider_id.as_str();
169 if !grouped.contains_key(id) {
170 order.push(id);
171 }
172 grouped.entry(id).or_default().push(frame.clone());
173 }
174
175 let legs = order.into_iter().map(|provider_id| {
176 let frames = grouped.remove(provider_id).unwrap_or_default();
177 self.verify_one_provider(provider_id, frames)
178 });
179
180 let mut outcome = VerifyOutcome::default();
181 for leg in join_all(legs).await {
182 outcome.retained.extend(leg.retained);
183 outcome.dropped.extend(leg.dropped);
184 }
185 outcome
186 }
187
188 async fn verify_one_provider(&self, provider_id: &str, frames: Vec<FrameId>) -> VerifyOutcome {
192 let mut outcome = VerifyOutcome::default();
193
194 let Some(provider) = self.provider(provider_id) else {
195 outcome.drop_all(frames, DropReason::UnknownProvider);
196 return outcome;
197 };
198 if !provider.capabilities().verify {
199 outcome.drop_all(frames, DropReason::VerifyUnsupported);
202 return outcome;
203 }
204
205 let (verifiable, undigested): (Vec<FrameId>, Vec<FrameId>) =
209 frames.into_iter().partition(FrameId::is_verifiable);
210 outcome.drop_all(undigested, DropReason::NoDigest);
211 if verifiable.is_empty() {
212 return outcome;
213 }
214
215 let request = VerifyRequest::new(verifiable.clone());
216 let response = match tokio::time::timeout(
217 self.per_provider_timeout,
218 provider.verify(&request),
219 )
220 .await
221 {
222 Ok(Ok(response)) => response,
223 Ok(Err(error)) => {
224 outcome.drop_all(verifiable, DropReason::VerifyFailed(error.to_string()));
225 return outcome;
226 }
227 Err(_) => {
228 let error = HostError::Timeout {
229 id: provider_id.to_string(),
230 timeout_ms: self.per_provider_timeout.as_millis() as u64,
231 };
232 outcome.drop_all(verifiable, DropReason::VerifyFailed(error.to_string()));
233 return outcome;
234 }
235 };
236
237 for frame in verifiable {
238 match response.verdict_for(&frame) {
241 Some(Verdict::Valid) => outcome.retained.push(frame),
242 Some(Verdict::Stale { replacement_digest }) => outcome.drop_one(
243 frame,
244 DropReason::Stale {
245 replacement_digest: replacement_digest.clone(),
246 },
247 ),
248 Some(Verdict::Gone) => outcome.drop_one(frame, DropReason::Gone),
249 Some(Verdict::Unknown) | None => outcome.drop_one(frame, DropReason::Unknown),
250 }
251 }
252 outcome
253 }
254
255 pub async fn query_provider(
261 &self,
262 id: &str,
263 query: &ContextQuery,
264 ) -> Result<ContextQueryResult, HostError> {
265 let provider = self
266 .providers
267 .iter()
268 .find(|p| p.id() == id)
269 .ok_or_else(|| HostError::UnknownProvider(id.to_string()))?;
270
271 match self.consent.evaluate(provider.id(), provider.info()) {
272 ConsentDecision::Permitted => {}
273 ConsentDecision::NeedsConsent => {
274 return Err(HostError::ConsentRequired {
275 id: id.to_string(),
276 data_flow: provider.info().data_flow.clone(),
277 });
278 }
279 ConsentDecision::NeedsReceipts(scopes) => {
280 return Err(HostError::ConsentScopeRequired {
281 id: id.to_string(),
282 scopes,
283 });
284 }
285 }
286
287 match tokio::time::timeout(self.per_provider_timeout, provider.query(query)).await {
288 Ok(result) => result,
289 Err(_) => Err(HostError::Timeout {
290 id: id.to_string(),
291 timeout_ms: self.per_provider_timeout.as_millis() as u64,
292 }),
293 }
294 }
295
296 pub async fn query_all(&self, query: &ContextQuery) -> FanOut {
302 use futures_util::future::join_all;
303
304 let futures: Vec<_> = self
305 .providers
306 .iter()
307 .filter(|p| capability_matches(p.capabilities(), query))
308 .map(|p| self.query_one_isolated(p.as_ref(), query))
309 .collect();
310
311 FanOut {
312 outcomes: join_all(futures).await,
313 }
314 }
315
316 pub async fn query_all_budgeted(&self, template: &ContextQuery, global_budget: u32) -> FanOut {
336 use futures_util::future::join_all;
337
338 let matching: Vec<&dyn ContextProvider> = self
341 .providers
342 .iter()
343 .map(|provider| provider.as_ref())
344 .filter(|provider| capability_matches(provider.capabilities(), template))
345 .collect();
346
347 let shares = crate::compose::budget_split(global_budget, matching.len());
350
351 let queries: Vec<ContextQuery> = shares
354 .iter()
355 .map(|&share| ContextQuery {
356 max_tokens: share,
357 ..template.clone()
358 })
359 .collect();
360
361 let futures: Vec<_> = matching
362 .iter()
363 .zip(queries.iter())
364 .map(|(provider, query)| self.query_one_isolated(*provider, query))
365 .collect();
366
367 FanOut {
368 outcomes: join_all(futures).await,
369 }
370 }
371
372 async fn query_one_isolated(
375 &self,
376 provider: &dyn ContextProvider,
377 query: &ContextQuery,
378 ) -> ProviderOutcome {
379 let id = provider.id().to_string();
380
381 match self.consent.evaluate(provider.id(), provider.info()) {
386 ConsentDecision::Permitted => {}
387 ConsentDecision::NeedsConsent => {
388 return ProviderOutcome {
389 provider_id: id,
390 result: ProviderResult::ConsentRequired(provider.info().data_flow.clone()),
391 };
392 }
393 ConsentDecision::NeedsReceipts(scopes) => {
394 return ProviderOutcome {
395 provider_id: id,
396 result: ProviderResult::ConsentScopeRequired {
397 data_flow: provider.info().data_flow.clone(),
398 missing: scopes,
399 },
400 };
401 }
402 }
403
404 let result =
405 match tokio::time::timeout(self.per_provider_timeout, provider.query(query)).await {
406 Ok(Ok(result)) => result,
407 Ok(Err(error)) => {
408 return ProviderOutcome {
409 provider_id: id,
410 result: ProviderResult::Failed(error),
411 };
412 }
413 Err(_) => {
414 let error = HostError::Timeout {
415 id: id.clone(),
416 timeout_ms: self.per_provider_timeout.as_millis() as u64,
417 };
418 return ProviderOutcome {
419 provider_id: id,
420 result: ProviderResult::Failed(error),
421 };
422 }
423 };
424
425 if !result.respects_budget(query.max_tokens) {
428 return ProviderOutcome {
429 provider_id: id,
430 result: ProviderResult::BudgetLie {
431 claimed_tokens: result.total_token_cost(),
432 max_tokens: query.max_tokens,
433 dropped_frames: result.frames.len(),
434 },
435 };
436 }
437
438 if !result.respects_frame_limit(query.max_frames) {
443 return ProviderOutcome {
444 provider_id: id,
445 result: ProviderResult::FrameFlood {
446 returned_frames: result.frames.len(),
447 max_frames: query.max_frames,
448 },
449 };
450 }
451
452 ProviderOutcome {
453 provider_id: id,
454 result: ProviderResult::Frames(result),
455 }
456 }
457
458 pub async fn shutdown(self) -> Vec<(String, Result<(), HostError>)> {
462 let mut results = Vec::with_capacity(self.providers.len());
463 for provider in &self.providers {
464 results.push((provider.id().to_string(), provider.shutdown().await));
465 }
466 results
467 }
468}
469
470#[derive(Debug)]
473pub struct FanOut {
474 pub outcomes: Vec<ProviderOutcome>,
477}
478
479impl FanOut {
480 pub fn accepted_frames(&self) -> impl Iterator<Item = &ContextFrame> {
484 self.outcomes
485 .iter()
486 .filter_map(|outcome| match &outcome.result {
487 ProviderResult::Frames(result) => Some(result.frames.iter()),
488 _ => None,
489 })
490 .flatten()
491 }
492
493 pub fn total_accepted_tokens(&self) -> u64 {
495 self.accepted_frames().map(|f| f.token_cost as u64).sum()
496 }
497
498 pub fn accepted_with_provider(&self) -> impl Iterator<Item = (&str, &ContextFrame)> {
501 self.outcomes
502 .iter()
503 .filter_map(|outcome| match &outcome.result {
504 ProviderResult::Frames(result) => Some(
505 result
506 .frames
507 .iter()
508 .map(move |frame| (outcome.provider_id.as_str(), frame)),
509 ),
510 _ => None,
511 })
512 .flatten()
513 }
514
515 pub fn compose(&self) -> String {
521 crate::compose::compose_context(self.accepted_with_provider())
522 }
523
524 pub fn compose_for_prompt(&self, global_budget: u32) -> crate::compose::ComposedPrompt {
533 crate::compose::compose_for_prompt(self.accepted_with_provider(), global_budget)
534 }
535
536 pub fn usage_report(&self, query: &ContextQuery, as_of: impl Into<String>) -> UsageReport {
549 let providers: Vec<ProviderUsage> = self
550 .outcomes
551 .iter()
552 .map(|outcome| {
553 let provider_id = outcome.provider_id.clone();
554 match &outcome.result {
555 ProviderResult::Frames(result) => {
556 let served_frames: Vec<ServedFrame> = result
557 .frames
558 .iter()
559 .map(|frame| ServedFrame {
560 frame: frame.identity(&provider_id),
561 token_cost: frame.token_cost,
562 })
563 .collect();
564 let token_cost = served_frames.iter().map(|s| s.token_cost as u64).sum();
565 ProviderUsage {
566 provider_id,
567 frames_served: served_frames.len() as u32,
568 frames_rejected: 0,
569 token_cost,
570 served_frames,
571 }
572 }
573 ProviderResult::BudgetLie { dropped_frames, .. } => ProviderUsage {
576 provider_id,
577 frames_served: 0,
578 frames_rejected: *dropped_frames as u32,
579 token_cost: 0,
580 served_frames: vec![],
581 },
582 ProviderResult::FrameFlood {
586 returned_frames, ..
587 } => ProviderUsage {
588 provider_id,
589 frames_served: 0,
590 frames_rejected: *returned_frames as u32,
591 token_cost: 0,
592 served_frames: vec![],
593 },
594 ProviderResult::ConsentRequired(_)
597 | ProviderResult::ConsentScopeRequired { .. }
598 | ProviderResult::Failed(_) => ProviderUsage {
599 provider_id,
600 frames_served: 0,
601 frames_rejected: 0,
602 token_cost: 0,
603 served_frames: vec![],
604 },
605 }
606 })
607 .collect();
608
609 let budget_consumed = providers.iter().map(|p| p.token_cost).sum();
610 UsageReport {
611 budget_requested: query.max_tokens,
612 budget_consumed,
613 as_of: as_of.into(),
614 providers,
615 }
616 }
617
618 pub fn failures(&self) -> impl Iterator<Item = (&str, &HostError)> {
620 self.outcomes
621 .iter()
622 .filter_map(|outcome| match &outcome.result {
623 ProviderResult::Failed(error) => Some((outcome.provider_id.as_str(), error)),
624 _ => None,
625 })
626 }
627
628 pub fn budget_liars(&self) -> impl Iterator<Item = &ProviderOutcome> {
631 self.outcomes
632 .iter()
633 .filter(|outcome| matches!(outcome.result, ProviderResult::BudgetLie { .. }))
634 }
635
636 pub fn frame_floods(&self) -> impl Iterator<Item = &ProviderOutcome> {
640 self.outcomes
641 .iter()
642 .filter(|outcome| matches!(outcome.result, ProviderResult::FrameFlood { .. }))
643 }
644}
645
646#[derive(Debug)]
648pub struct ProviderOutcome {
649 pub provider_id: String,
650 pub result: ProviderResult,
651}
652
653#[derive(Debug)]
656pub enum ProviderResult {
657 Frames(ContextQueryResult),
659 BudgetLie {
662 claimed_tokens: u64,
663 max_tokens: u32,
664 dropped_frames: usize,
665 },
666 FrameFlood {
670 returned_frames: usize,
671 max_frames: u32,
672 },
673 ConsentRequired(DataFlow),
676 ConsentScopeRequired {
680 data_flow: DataFlow,
681 missing: Vec<EgressScope>,
682 },
683 Failed(HostError),
685}
686
687#[derive(Debug, Clone, PartialEq, Eq)]
695pub enum DropReason {
696 Stale { replacement_digest: Option<String> },
699 Gone,
701 Unknown,
704 NoDigest,
707 VerifyUnsupported,
710 UnknownProvider,
712 VerifyFailed(String),
714}
715
716impl DropReason {
717 pub fn warrants_requery(&self) -> bool {
721 !matches!(self, Self::Gone)
722 }
723}
724
725#[derive(Debug, Clone, PartialEq, Eq)]
727pub struct DroppedFrame {
728 pub frame: FrameId,
730 pub reason: DropReason,
732}
733
734#[derive(Debug, Clone, Default, PartialEq, Eq)]
741pub struct VerifyOutcome {
742 pub retained: Vec<FrameId>,
745 pub dropped: Vec<DroppedFrame>,
747}
748
749impl VerifyOutcome {
750 pub fn requery(&self) -> impl Iterator<Item = &FrameId> {
753 self.dropped
754 .iter()
755 .filter(|dropped| dropped.reason.warrants_requery())
756 .map(|dropped| &dropped.frame)
757 }
758
759 pub fn was_dropped(&self, frame: &FrameId) -> bool {
761 self.dropped.iter().any(|dropped| &dropped.frame == frame)
762 }
763
764 pub fn drop_reason(&self, frame: &FrameId) -> Option<&DropReason> {
766 self.dropped
767 .iter()
768 .find(|dropped| &dropped.frame == frame)
769 .map(|dropped| &dropped.reason)
770 }
771
772 fn drop_one(&mut self, frame: FrameId, reason: DropReason) {
773 self.dropped.push(DroppedFrame { frame, reason });
774 }
775
776 fn drop_all(&mut self, frames: impl IntoIterator<Item = FrameId>, reason: DropReason) {
777 for frame in frames {
778 self.drop_one(frame, reason.clone());
779 }
780 }
781}
782
783#[cfg(test)]
784mod tests {
785 use super::*;
786 use async_trait::async_trait;
787 use contextgraph_types::Grantor;
788 use contextgraph_types::capability::QueryCapability;
789 use contextgraph_types::{Capabilities, ContextFrame, FrameKind, ProviderInfo};
790 use std::sync::Arc;
791 use std::sync::atomic::{AtomicBool, Ordering};
792
793 struct FakeProvider {
795 id: String,
796 info: ProviderInfo,
797 capabilities: Capabilities,
798 behavior: Behavior,
799 queried: Arc<AtomicBool>,
800 }
801
802 enum Behavior {
803 Frames(Vec<ContextFrame>),
804 Fail(String),
805 Slow(Duration),
806 }
807
808 impl FakeProvider {
809 fn new(id: &str, egress: bool, behavior: Behavior) -> Self {
810 Self::with_data_flow(
811 id,
812 DataFlow {
813 reads: true,
814 writes: false,
815 egress,
816 egress_scopes: vec![],
817 },
818 behavior,
819 )
820 }
821
822 fn scoped(id: &str, scopes: Vec<EgressScope>, behavior: Behavior) -> Self {
824 Self::with_data_flow(
825 id,
826 DataFlow {
827 reads: true,
828 writes: false,
829 egress: true,
830 egress_scopes: scopes,
831 },
832 behavior,
833 )
834 }
835
836 fn with_data_flow(id: &str, data_flow: DataFlow, behavior: Behavior) -> Self {
837 Self {
838 id: id.into(),
839 info: ProviderInfo {
840 name: id.into(),
841 version: "0.0.1".into(),
842 data_flow,
843 },
844 capabilities: Capabilities {
845 query: QueryCapability {
846 kinds: vec!["doc".into()],
847 },
848 ..Capabilities::default()
849 },
850 behavior,
851 queried: Arc::new(AtomicBool::new(false)),
852 }
853 }
854 }
855
856 #[async_trait]
857 impl ContextProvider for FakeProvider {
858 fn id(&self) -> &str {
859 &self.id
860 }
861 fn info(&self) -> &ProviderInfo {
862 &self.info
863 }
864 fn capabilities(&self) -> &Capabilities {
865 &self.capabilities
866 }
867 async fn query(&self, _query: &ContextQuery) -> Result<ContextQueryResult, HostError> {
868 self.queried.store(true, Ordering::SeqCst);
869 match &self.behavior {
870 Behavior::Frames(frames) => Ok(ContextQueryResult {
871 frames: frames.clone(),
872 truncated: false,
873 dropped_estimate: None,
874 }),
875 Behavior::Fail(message) => Err(HostError::Provider {
876 id: self.id.clone(),
877 code: None,
878 message: message.clone(),
879 }),
880 Behavior::Slow(duration) => {
881 tokio::time::sleep(*duration).await;
882 Ok(ContextQueryResult {
883 frames: vec![],
884 truncated: false,
885 dropped_estimate: None,
886 })
887 }
888 }
889 }
890 }
891
892 fn frame(id: &str, cost: u32) -> ContextFrame {
893 ContextFrame {
894 id: id.into(),
895 kind: FrameKind::Doc,
896 title: id.into(),
897 content: Some("c".into()),
898 content_digest: None,
899 uri: None,
900 representation: Default::default(),
901 content_fidelity: None,
902 canonical_content_hash: None,
903 content_ref: None,
904 transform: None,
905 minimum_content_fidelity: None,
906 inline_content_requirement: None,
907 score: 0.5,
908 token_cost: cost,
909 canonical_token_cost: None,
910 tokenizer_ref: None,
911 valid_from: None,
912 valid_to: None,
913 recorded_at: None,
914 provenance: vec![],
915 citation_label: Some(id.into()),
916 embedding: None,
917 relations: vec![],
918 }
919 }
920
921 fn query() -> ContextQuery {
922 ContextQuery {
923 goal: "g".into(),
924 query_text: None,
925 embedding: None,
926 kinds: vec![],
927 anchors: vec![],
928 max_frames: 10,
929 max_tokens: 1000,
930 as_of: None,
931 representation_preferences: vec![],
932 }
933 }
934
935 #[tokio::test]
936 async fn query_all_collects_frames_from_healthy_providers() {
937 let mut host = Host::new();
938 host.register(Box::new(FakeProvider::new(
939 "a",
940 false,
941 Behavior::Frames(vec![frame("f1", 100), frame("f2", 100)]),
942 )));
943 host.register(Box::new(FakeProvider::new(
944 "b",
945 false,
946 Behavior::Frames(vec![frame("f3", 50)]),
947 )));
948
949 let fanout = host.query_all(&query()).await;
950 assert_eq!(fanout.outcomes.len(), 2);
951 assert_eq!(fanout.accepted_frames().count(), 3);
952 assert_eq!(fanout.total_accepted_tokens(), 250);
953 }
954
955 #[tokio::test]
956 async fn a_budgeted_fan_out_keeps_honest_legs_under_the_global_budget() {
957 let mut host = Host::new();
963 for id in ["a", "b", "c", "d"] {
964 host.register(Box::new(FakeProvider::new(
965 id,
966 false,
967 Behavior::Frames(vec![frame(&format!("{id}1"), 200)]),
968 )));
969 }
970 let template = query(); let fanout = host.query_all_budgeted(&template, 1000).await;
972 assert_eq!(
973 fanout.accepted_frames().count(),
974 4,
975 "each share fits its leg"
976 );
977 assert!(
978 fanout.total_accepted_tokens() <= 1000,
979 "honest legs must sum to <= the global budget, got {}",
980 fanout.total_accepted_tokens()
981 );
982 assert_eq!(fanout.budget_liars().count(), 0);
983 }
984
985 #[tokio::test]
986 async fn the_budget_split_enforces_a_per_leg_ceiling_the_flat_fan_out_does_not() {
987 let mut host = Host::new();
993 host.register(Box::new(FakeProvider::new(
994 "greedy",
995 false,
996 Behavior::Frames(vec![frame("g", 300)]),
997 )));
998 for id in ["b", "c", "d"] {
999 host.register(Box::new(FakeProvider::new(
1000 id,
1001 false,
1002 Behavior::Frames(vec![frame(&format!("{id}1"), 100)]),
1003 )));
1004 }
1005
1006 let budgeted = host.query_all_budgeted(&query(), 1000).await;
1007 assert!(
1008 budgeted
1009 .budget_liars()
1010 .any(|outcome| outcome.provider_id == "greedy"),
1011 "a leg overspending its share is dropped by the existing B2 audit"
1012 );
1013 assert!(
1014 budgeted
1015 .accepted_with_provider()
1016 .all(|(id, _)| id != "greedy"),
1017 "the greedy leg contributes nothing under the split"
1018 );
1019
1020 let flat = host.query_all(&query()).await;
1023 assert!(flat.accepted_with_provider().any(|(id, _)| id == "greedy"));
1024 }
1025
1026 #[tokio::test]
1027 async fn the_host_composes_the_same_frame_set_to_identical_bytes_across_turns() {
1028 let mut host = Host::new();
1033 host.register(Box::new(FakeProvider::new(
1034 "prov-b",
1035 false,
1036 Behavior::Frames(vec![frame("f2", 100), frame("f1", 100)]),
1037 )));
1038 host.register(Box::new(FakeProvider::new(
1039 "prov-a",
1040 false,
1041 Behavior::Frames(vec![frame("f3", 50)]),
1042 )));
1043
1044 let first = host.query_all(&query()).await.compose();
1045 let second = host.query_all(&query()).await.compose();
1046 assert_eq!(
1047 first, second,
1048 "an unchanged frame set must compose to identical bytes"
1049 );
1050 assert_eq!(first.matches("<frame ").count(), 3);
1054 assert!(first.find("prov-a").unwrap() < first.find("prov-b").unwrap());
1055 }
1056
1057 #[tokio::test]
1058 async fn a_fan_out_rolls_up_into_a_self_consistent_usage_report() {
1059 let mut host = Host::new();
1060 host.register(Box::new(FakeProvider::new(
1061 "a",
1062 false,
1063 Behavior::Frames(vec![frame("f1", 100), frame("f2", 100)]),
1064 )));
1065 host.register(Box::new(FakeProvider::new(
1067 "liar",
1068 false,
1069 Behavior::Frames(vec![frame("big", 1200)]),
1070 )));
1071
1072 let query = query();
1073 let fanout = host.query_all(&query).await;
1074 let report = fanout.usage_report(&query, "2026-07-21T00:00:00Z");
1075
1076 assert_eq!(report.budget_requested, 1000);
1077 assert_eq!(report.budget_consumed, 200);
1078 assert_eq!(report.as_of, "2026-07-21T00:00:00Z");
1079 assert!(report.is_consistent());
1081 assert!(report.within_budget());
1082 let independent: u64 = fanout.accepted_frames().map(|f| f.token_cost as u64).sum();
1085 assert_eq!(report.budget_consumed, independent);
1086 assert_eq!(report.budget_consumed, fanout.total_accepted_tokens());
1087
1088 let a = report
1089 .providers
1090 .iter()
1091 .find(|p| p.provider_id == "a")
1092 .expect("provider a is in the report");
1093 assert_eq!(a.frames_served, 2);
1094 assert_eq!(a.frames_rejected, 0);
1095 assert_eq!(a.token_cost, 200);
1096 let ids: Vec<&str> = a
1098 .served_frames
1099 .iter()
1100 .map(|s| s.frame.frame_id.as_str())
1101 .collect();
1102 assert!(ids.contains(&"f1") && ids.contains(&"f2"));
1103 assert!(a.served_frames.iter().all(|s| s.frame.provider_id == "a"));
1104
1105 let liar = report
1106 .providers
1107 .iter()
1108 .find(|p| p.provider_id == "liar")
1109 .expect("the liar is still accounted for");
1110 assert_eq!(liar.frames_served, 0);
1111 assert_eq!(liar.frames_rejected, 1);
1112 assert_eq!(liar.token_cost, 0);
1113 assert!(liar.served_frames.is_empty());
1114 }
1115
1116 #[tokio::test]
1117 async fn a_provider_lying_about_token_cost_has_its_frames_dropped_loudly() {
1118 let mut host = Host::new();
1119 host.register(Box::new(FakeProvider::new(
1121 "liar",
1122 false,
1123 Behavior::Frames(vec![frame("big", 1200)]),
1124 )));
1125 host.register(Box::new(FakeProvider::new(
1126 "honest",
1127 false,
1128 Behavior::Frames(vec![frame("ok", 200)]),
1129 )));
1130
1131 let fanout = host.query_all(&query()).await;
1132 assert_eq!(fanout.accepted_frames().count(), 1);
1134 assert_eq!(fanout.total_accepted_tokens(), 200);
1135 let liars: Vec<_> = fanout.budget_liars().collect();
1137 assert_eq!(liars.len(), 1);
1138 assert_eq!(liars[0].provider_id, "liar");
1139 match liars[0].result {
1140 ProviderResult::BudgetLie {
1141 claimed_tokens,
1142 max_tokens,
1143 dropped_frames,
1144 } => {
1145 assert_eq!(claimed_tokens, 1200);
1146 assert_eq!(max_tokens, 1000);
1147 assert_eq!(dropped_frames, 1);
1148 }
1149 _ => unreachable!(),
1150 }
1151 }
1152
1153 #[tokio::test]
1154 async fn a_provider_returning_more_than_max_frames_has_them_dropped_loudly() {
1155 let query = query();
1160 let flood: Vec<ContextFrame> = (0..12).map(|i| frame(&format!("f{i}"), 1)).collect();
1161 let as_result = ContextQueryResult {
1164 frames: flood.clone(),
1165 truncated: false,
1166 dropped_estimate: None,
1167 };
1168 assert!(as_result.respects_budget(query.max_tokens));
1169 assert!(!as_result.respects_frame_limit(query.max_frames));
1170
1171 let mut host = Host::new();
1172 host.register(Box::new(FakeProvider::new(
1173 "flood",
1174 false,
1175 Behavior::Frames(flood),
1176 )));
1177 host.register(Box::new(FakeProvider::new(
1178 "honest",
1179 false,
1180 Behavior::Frames(vec![frame("ok", 200)]),
1181 )));
1182
1183 let fanout = host.query_all(&query).await;
1184 assert_eq!(fanout.accepted_frames().count(), 1);
1186 assert_eq!(fanout.total_accepted_tokens(), 200);
1187
1188 let floods: Vec<_> = fanout.frame_floods().collect();
1190 assert_eq!(floods.len(), 1);
1191 assert_eq!(floods[0].provider_id, "flood");
1192 match floods[0].result {
1193 ProviderResult::FrameFlood {
1194 returned_frames,
1195 max_frames,
1196 } => {
1197 assert_eq!(returned_frames, 12);
1198 assert_eq!(max_frames, 10);
1199 }
1200 _ => unreachable!(),
1201 }
1202 assert_eq!(fanout.budget_liars().count(), 0);
1204
1205 let report = fanout.usage_report(&query, "2026-07-21T00:00:00Z");
1207 let flooder = report
1208 .providers
1209 .iter()
1210 .find(|p| p.provider_id == "flood")
1211 .expect("the flooder is still accounted for");
1212 assert_eq!(flooder.frames_served, 0);
1213 assert_eq!(flooder.frames_rejected, 12);
1214 assert_eq!(flooder.token_cost, 0);
1215 assert!(flooder.served_frames.is_empty());
1216 assert!(report.is_consistent());
1217 }
1218
1219 #[tokio::test]
1220 async fn one_failing_provider_never_poisons_the_others() {
1221 let mut host = Host::new();
1222 host.register(Box::new(FakeProvider::new(
1223 "healthy",
1224 false,
1225 Behavior::Frames(vec![frame("f", 10)]),
1226 )));
1227 host.register(Box::new(FakeProvider::new(
1228 "broken",
1229 false,
1230 Behavior::Fail("kaboom".into()),
1231 )));
1232
1233 let fanout = host.query_all(&query()).await;
1234 assert_eq!(fanout.accepted_frames().count(), 1);
1235 let failures: Vec<_> = fanout.failures().collect();
1236 assert_eq!(failures.len(), 1);
1237 assert_eq!(failures[0].0, "broken");
1238 }
1239
1240 #[tokio::test]
1241 async fn a_slow_provider_is_timed_out_without_stalling_the_fan_out() {
1242 let mut host = Host::with_timeout(Duration::from_millis(50));
1243 host.register(Box::new(FakeProvider::new(
1244 "fast",
1245 false,
1246 Behavior::Frames(vec![frame("f", 10)]),
1247 )));
1248 host.register(Box::new(FakeProvider::new(
1249 "slow",
1250 false,
1251 Behavior::Slow(Duration::from_secs(30)),
1252 )));
1253
1254 let fanout = host.query_all(&query()).await;
1255 assert_eq!(fanout.accepted_frames().count(), 1);
1256 let failures: Vec<_> = fanout.failures().collect();
1257 assert_eq!(failures.len(), 1);
1258 assert_eq!(failures[0].0, "slow");
1259 assert!(matches!(failures[0].1, HostError::Timeout { .. }));
1260 }
1261
1262 #[tokio::test]
1263 async fn an_egress_provider_is_not_queried_until_consent_is_recorded() {
1264 let mut host = Host::new();
1265 let provider = FakeProvider::new("github", true, Behavior::Frames(vec![frame("f", 10)]));
1266 let queried = provider.queried.clone();
1267 host.register(Box::new(provider));
1268
1269 let fanout = host.query_all(&query()).await;
1272 assert_eq!(fanout.accepted_frames().count(), 0);
1273 assert!(!queried.load(Ordering::SeqCst), "payload must not be sent");
1274 assert!(matches!(
1275 fanout.outcomes[0].result,
1276 ProviderResult::ConsentRequired(_)
1277 ));
1278 assert!(matches!(
1280 host.query_provider("github", &query()).await,
1281 Err(HostError::ConsentRequired { .. })
1282 ));
1283
1284 host.record_consent(ConsentRecord::new(
1286 "github",
1287 DataFlow {
1288 reads: true,
1289 writes: false,
1290 egress: true,
1291 egress_scopes: vec![],
1292 },
1293 "issue titles leave to github.com",
1294 ));
1295 let fanout = host.query_all(&query()).await;
1296 assert!(queried.load(Ordering::SeqCst));
1297 assert_eq!(fanout.accepted_frames().count(), 1);
1298 }
1299
1300 #[tokio::test]
1301 async fn a_scoped_egress_provider_is_not_queried_until_a_receipt_is_recorded() {
1302 let mut host = Host::new();
1303 let provider = FakeProvider::scoped(
1304 "cloud",
1305 vec![EgressScope::ThirdPartyModel],
1306 Behavior::Frames(vec![frame("f", 10)]),
1307 );
1308 let queried = provider.queried.clone();
1309 let info = provider.info().clone();
1310 host.register(Box::new(provider));
1311
1312 let fanout = host.query_all(&query()).await;
1315 assert_eq!(fanout.accepted_frames().count(), 0);
1316 assert!(!queried.load(Ordering::SeqCst), "payload must not be sent");
1317 match &fanout.outcomes[0].result {
1318 ProviderResult::ConsentScopeRequired { missing, .. } => {
1319 assert_eq!(missing, &vec![EgressScope::ThirdPartyModel]);
1320 }
1321 other => panic!("expected ConsentScopeRequired, got {other:?}"),
1322 }
1323 match host.query_provider("cloud", &query()).await {
1325 Err(HostError::ConsentScopeRequired { scopes, .. }) => {
1326 assert_eq!(scopes, vec![EgressScope::ThirdPartyModel]);
1327 }
1328 other => panic!("expected ConsentScopeRequired error, got {other:?}"),
1329 }
1330
1331 host.record_receipt(ConsentReceipt::new(
1333 "cloud",
1334 &info,
1335 EgressScope::ThirdPartyModel,
1336 Grantor::Human("ops@oxagen.sh".into()),
1337 "2026-07-21T00:00:00Z",
1338 ));
1339 let fanout = host.query_all(&query()).await;
1340 assert!(queried.load(Ordering::SeqCst));
1341 assert_eq!(fanout.accepted_frames().count(), 1);
1342 }
1343
1344 #[tokio::test]
1345 async fn query_provider_reports_unknown_ids() {
1346 let host = Host::new();
1347 assert!(matches!(
1348 host.query_provider("nope", &query()).await,
1349 Err(HostError::UnknownProvider(_))
1350 ));
1351 }
1352
1353 use contextgraph_types::{FrameVerdict, VerifyResponse};
1356 use std::collections::HashMap as StdHashMap;
1357
1358 struct VerifyingProvider {
1360 id: String,
1361 capabilities: Capabilities,
1362 verdicts: StdHashMap<String, Verdict>,
1365 verify_error: Option<String>,
1367 asked: Arc<std::sync::Mutex<Vec<FrameId>>>,
1369 }
1370
1371 impl VerifyingProvider {
1372 fn new(id: &str, supports_verify: bool, verdicts: &[(&str, Verdict)]) -> Self {
1373 Self {
1374 id: id.into(),
1375 capabilities: Capabilities {
1376 query: QueryCapability {
1377 kinds: vec!["doc".into()],
1378 },
1379 verify: supports_verify,
1380 ..Capabilities::default()
1381 },
1382 verdicts: verdicts
1383 .iter()
1384 .map(|(f, v)| ((*f).to_string(), v.clone()))
1385 .collect(),
1386 verify_error: None,
1387 asked: Arc::new(std::sync::Mutex::new(Vec::new())),
1388 }
1389 }
1390
1391 fn failing(id: &str) -> Self {
1392 let mut provider = Self::new(id, true, &[]);
1393 provider.verify_error = Some("index unavailable".into());
1394 provider
1395 }
1396 }
1397
1398 #[async_trait]
1399 impl ContextProvider for VerifyingProvider {
1400 fn id(&self) -> &str {
1401 &self.id
1402 }
1403 fn info(&self) -> &ProviderInfo {
1404 static_info()
1406 }
1407 fn capabilities(&self) -> &Capabilities {
1408 &self.capabilities
1409 }
1410 async fn query(&self, _query: &ContextQuery) -> Result<ContextQueryResult, HostError> {
1411 Ok(ContextQueryResult {
1412 frames: vec![],
1413 truncated: false,
1414 dropped_estimate: None,
1415 })
1416 }
1417 async fn verify(&self, request: &VerifyRequest) -> Result<VerifyResponse, HostError> {
1418 self.asked
1419 .lock()
1420 .unwrap()
1421 .extend(request.frames.iter().cloned());
1422 if let Some(message) = &self.verify_error {
1423 return Err(HostError::Provider {
1424 id: self.id.clone(),
1425 code: None,
1426 message: message.clone(),
1427 });
1428 }
1429 Ok(VerifyResponse::new(
1430 request
1431 .frames
1432 .iter()
1433 .filter_map(|frame| {
1434 self.verdicts
1435 .get(&frame.frame_id)
1436 .map(|verdict| FrameVerdict::new(frame.clone(), verdict.clone()))
1437 })
1438 .collect(),
1439 ))
1440 }
1441 }
1442
1443 fn static_info() -> &'static ProviderInfo {
1444 use std::sync::OnceLock;
1445 static INFO: OnceLock<ProviderInfo> = OnceLock::new();
1446 INFO.get_or_init(|| ProviderInfo {
1447 name: "verifier".into(),
1448 version: "0.0.1".into(),
1449 data_flow: DataFlow {
1450 reads: true,
1451 writes: false,
1452 egress: false,
1453 egress_scopes: vec![],
1454 },
1455 })
1456 }
1457
1458 fn held(provider: &str, frame: &str, digest: Option<&str>) -> FrameId {
1459 FrameId::new(provider, frame, digest.map(String::from))
1460 }
1461
1462 #[tokio::test]
1463 async fn a_stale_frame_is_dropped_and_a_valid_one_is_retained() {
1464 let mut host = Host::new();
1467 host.register(Box::new(VerifyingProvider::new(
1468 "docs",
1469 true,
1470 &[
1471 ("fresh", Verdict::Valid),
1472 (
1473 "changed",
1474 Verdict::Stale {
1475 replacement_digest: Some("sha256:new".into()),
1476 },
1477 ),
1478 ],
1479 )));
1480
1481 let fresh = held("docs", "fresh", Some("sha256:a"));
1482 let changed = held("docs", "changed", Some("sha256:b"));
1483 let outcome = host.verify_frames(&[fresh.clone(), changed.clone()]).await;
1484
1485 assert_eq!(outcome.retained, vec![fresh]);
1486 assert!(outcome.was_dropped(&changed));
1487 assert_eq!(
1488 outcome.drop_reason(&changed),
1489 Some(&DropReason::Stale {
1490 replacement_digest: Some("sha256:new".into())
1491 })
1492 );
1493 assert_eq!(outcome.requery().collect::<Vec<_>>(), vec![&changed]);
1496 }
1497
1498 #[tokio::test]
1499 async fn a_gone_frame_is_dropped_and_not_worth_re_querying() {
1500 let mut host = Host::new();
1501 host.register(Box::new(VerifyingProvider::new(
1502 "docs",
1503 true,
1504 &[("deleted", Verdict::Gone)],
1505 )));
1506 let deleted = held("docs", "deleted", Some("sha256:a"));
1507 let outcome = host.verify_frames(std::slice::from_ref(&deleted)).await;
1508
1509 assert!(outcome.retained.is_empty());
1510 assert_eq!(outcome.drop_reason(&deleted), Some(&DropReason::Gone));
1511 assert_eq!(outcome.requery().count(), 0);
1513 }
1514
1515 #[tokio::test]
1516 async fn an_unknown_verdict_and_a_missing_verdict_both_drop_the_frame() {
1517 let mut host = Host::new();
1520 host.register(Box::new(VerifyingProvider::new(
1521 "docs",
1522 true,
1523 &[("shrugged", Verdict::Unknown)],
1524 )));
1525 let shrugged = held("docs", "shrugged", Some("sha256:a"));
1526 let unanswered = held("docs", "never-mentioned", Some("sha256:b"));
1527 let outcome = host
1528 .verify_frames(&[shrugged.clone(), unanswered.clone()])
1529 .await;
1530
1531 assert!(outcome.retained.is_empty());
1532 assert_eq!(outcome.drop_reason(&shrugged), Some(&DropReason::Unknown));
1533 assert_eq!(outcome.drop_reason(&unanswered), Some(&DropReason::Unknown));
1534 assert_eq!(outcome.requery().count(), 2);
1535 }
1536
1537 #[tokio::test]
1538 async fn a_provider_without_verify_support_is_never_asked_and_falls_back_to_requery() {
1539 let mut host = Host::new();
1542 let provider = VerifyingProvider::new("docs", false, &[("anything", Verdict::Valid)]);
1543 let asked = provider.asked.clone();
1544 host.register(Box::new(provider));
1545
1546 let frame = held("docs", "anything", Some("sha256:a"));
1547 let outcome = host.verify_frames(std::slice::from_ref(&frame)).await;
1548
1549 assert!(asked.lock().unwrap().is_empty(), "must not be asked");
1550 assert!(outcome.retained.is_empty());
1551 assert_eq!(
1552 outcome.drop_reason(&frame),
1553 Some(&DropReason::VerifyUnsupported)
1554 );
1555 assert_eq!(outcome.requery().count(), 1);
1556 }
1557
1558 #[tokio::test]
1559 async fn a_frame_without_a_digest_is_unverifiable_and_never_sent() {
1560 let mut host = Host::new();
1563 let provider = VerifyingProvider::new("docs", true, &[("bare", Verdict::Valid)]);
1564 let asked = provider.asked.clone();
1565 host.register(Box::new(provider));
1566
1567 let bare = held("docs", "bare", None);
1568 let digested = held("docs", "digested", Some("sha256:a"));
1569 let outcome = host.verify_frames(&[bare.clone(), digested.clone()]).await;
1570
1571 assert_eq!(outcome.drop_reason(&bare), Some(&DropReason::NoDigest));
1572 let asked = asked.lock().unwrap().clone();
1573 assert_eq!(
1574 asked,
1575 vec![digested],
1576 "only verifiable identities go on the wire"
1577 );
1578 }
1579
1580 #[tokio::test]
1581 async fn a_failed_verify_drops_that_providers_frames_without_touching_another() {
1582 let mut host = Host::new();
1584 host.register(Box::new(VerifyingProvider::failing("broken")));
1585 host.register(Box::new(VerifyingProvider::new(
1586 "healthy",
1587 true,
1588 &[("good", Verdict::Valid)],
1589 )));
1590
1591 let broken = held("broken", "any", Some("sha256:a"));
1592 let good = held("healthy", "good", Some("sha256:b"));
1593 let outcome = host.verify_frames(&[broken.clone(), good.clone()]).await;
1594
1595 assert_eq!(outcome.retained, vec![good], "one failure must not poison");
1596 assert!(matches!(
1597 outcome.drop_reason(&broken),
1598 Some(DropReason::VerifyFailed(_))
1599 ));
1600 assert!(outcome.requery().any(|frame| frame == &broken));
1601 }
1602
1603 #[tokio::test]
1604 async fn frames_from_an_unregistered_provider_are_dropped_not_ignored() {
1605 let host = Host::new();
1606 let orphan = held("never-registered", "f", Some("sha256:a"));
1607 let outcome = host.verify_frames(std::slice::from_ref(&orphan)).await;
1608 assert!(outcome.retained.is_empty());
1609 assert_eq!(
1610 outcome.drop_reason(&orphan),
1611 Some(&DropReason::UnknownProvider)
1612 );
1613 }
1614
1615 #[tokio::test]
1616 async fn held_frames_are_grouped_into_one_request_per_provider() {
1617 let mut host = Host::new();
1620 let docs = VerifyingProvider::new(
1621 "docs",
1622 true,
1623 &[("a", Verdict::Valid), ("b", Verdict::Valid)],
1624 );
1625 let asked = docs.asked.clone();
1626 host.register(Box::new(docs));
1627
1628 let outcome = host
1629 .verify_frames(&[
1630 held("docs", "a", Some("sha256:1")),
1631 held("docs", "b", Some("sha256:2")),
1632 ])
1633 .await;
1634 assert_eq!(outcome.retained.len(), 2);
1635 assert_eq!(asked.lock().unwrap().len(), 2);
1637 }
1638
1639 #[tokio::test]
1640 async fn the_partition_is_total_so_every_held_frame_is_accounted_for() {
1641 let mut host = Host::new();
1642 host.register(Box::new(VerifyingProvider::new(
1643 "docs",
1644 true,
1645 &[("keep", Verdict::Valid), ("drop", Verdict::Gone)],
1646 )));
1647 let input = vec![
1648 held("docs", "keep", Some("sha256:1")),
1649 held("docs", "drop", Some("sha256:2")),
1650 held("docs", "nodigest", None),
1651 held("elsewhere", "orphan", Some("sha256:3")),
1652 ];
1653 let outcome = host.verify_frames(&input).await;
1654 assert_eq!(
1655 outcome.retained.len() + outcome.dropped.len(),
1656 input.len(),
1657 "every held identity must land in exactly one bucket"
1658 );
1659 for frame in &input {
1660 assert!(
1661 outcome.retained.contains(frame) || outcome.was_dropped(frame),
1662 "{frame:?} was silently lost"
1663 );
1664 }
1665 }
1666
1667 #[tokio::test]
1668 async fn verifying_an_empty_held_set_is_a_no_op() {
1669 let host = Host::new();
1670 let outcome = host.verify_frames(&[]).await;
1671 assert_eq!(outcome, VerifyOutcome::default());
1672 }
1673}