Skip to main content

contextgraph_host/
host.rs

1//! The [`Host`] — one uniform handle over every provider, and the fan-out
2//! router.
3//!
4//! The host does the four jobs providers never do: routes a query to
5//! capability-matching providers (`SPEC.md` §5), gates consent so nothing
6//! reaches an unconsented egress provider (`SPEC.md` §4, C1–C2), enforces
7//! per-provider timeouts, and audits budget honesty on two axes — a provider
8//! whose frames sum above the query budget lied about `token_cost` (`SPEC.md`
9//! §7, B2), and one that returns more frames than `max_frames` overspent a
10//! budget the token count never captures (`SPEC.md` §7, B4). Either way its
11//! frames are dropped with a loud named report rather than silently trusted.
12//! Per-provider isolation is total: one provider erroring, timing out, being
13//! dropped for a budget lie, or crashing mid-query never poisons the others
14//! (task deliverable 5).
15
16use 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
29/// Default per-provider query budget — a slow or hung provider is cut off at
30/// this and reported as [`HostError::Timeout`], never allowed to stall the
31/// fan-out.
32const DEFAULT_PROVIDER_TIMEOUT: Duration = Duration::from_secs(30);
33
34/// Registers in-process, stdio, and HTTP providers behind one handle and
35/// fans queries out across them.
36pub 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    /// A host with no providers and the default per-provider timeout.
50    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    /// A host with a custom per-provider timeout.
59    pub fn with_timeout(per_provider_timeout: Duration) -> Self {
60        Self {
61            per_provider_timeout,
62            ..Self::new()
63        }
64    }
65
66    /// Register an in-process provider (a built-in, e.g. the code graph).
67    pub fn register(&mut self, provider: Box<dyn ContextProvider>) {
68        self.providers.push(provider);
69    }
70
71    /// Spawn and register a child-process provider over stdio, completing the
72    /// handshake (`SPEC.md` §3).
73    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    /// Connect and register a remote HTTP provider, completing the handshake.
85    ///
86    /// `credential` is an optional bearer [`Credential`](crate::http::Credential)
87    /// attached to every request; pass `None` for an unauthenticated provider.
88    /// A plaintext (`http://`) transport to a non-loopback provider is refused
89    /// before any bytes leave the host ([`HostError::InsecureTransport`], C7),
90    /// and the credential is never logged (C8).
91    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    /// Record legacy boolean consent for a provider, unlocking an egress
103    /// provider that declares no scopes for querying (§3.5).
104    pub fn record_consent(&mut self, record: ConsentRecord) {
105        self.consent.record(record);
106    }
107
108    /// Append a scope-level [`ConsentReceipt`] to the audit ledger, authorizing
109    /// one egress scope for one provider (`docs/context-reuse.md` §3). A
110    /// provider that declares off-machine egress scopes stays gated until every
111    /// such scope has a receipt.
112    pub fn record_receipt(&mut self, receipt: ConsentReceipt) {
113        self.consent.record_receipt(receipt);
114    }
115
116    /// The consent store (read-only), e.g. to persist decisions.
117    pub fn consent(&self) -> &ConsentStore {
118        &self.consent
119    }
120
121    /// The ids of every registered provider, in registration order.
122    pub fn provider_ids(&self) -> Vec<&str> {
123        self.providers.iter().map(|p| p.id()).collect()
124    }
125
126    /// Borrow a registered provider by id, e.g. to read its cached
127    /// capabilities.
128    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    /// How many providers are registered.
136    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    /// Revalidate frames this host already holds, so unchanged context can be
145    /// reused without re-querying it (`docs/context-reuse.md` §4).
146    ///
147    /// Identities are grouped by provider and each capable provider is asked
148    /// once. The rule is **default-deny**: a frame is retained only on an
149    /// explicit [`Verdict::Valid`], and every other outcome — a negative
150    /// verdict, a missing digest, a provider that doesn't support verify, an
151    /// unregistered provider, a failed request — drops the frame with a reason
152    /// (requirement V2). Reasons that
153    /// [warrant a re-query](DropReason::warrants_requery) tell the host which
154    /// dropped frames are worth fetching again.
155    ///
156    /// This method holds **no state**: it neither caches frames nor tracks turn
157    /// boundaries. When to re-verify is the host's policy (§4 gives informative
158    /// guidance); the protocol's job is only to answer the question when asked.
159    /// No frame body travels in either direction.
160    pub async fn verify_frames(&self, held: &[FrameId]) -> VerifyOutcome {
161        use futures_util::future::join_all;
162
163        // Group by provider, preserving first-seen provider order so the
164        // outcome is deterministic for a given input.
165        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    /// Verify one provider's slice of the held set, converting every failure
189    /// mode into dropped frames rather than a propagated error — one provider's
190    /// verify failure never affects another's.
191    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            // The declared fallback: a provider that can't verify gets its
200            // frames re-queried rather than trusted (§4, requirement V3).
201            outcome.drop_all(frames, DropReason::VerifyUnsupported);
202            return outcome;
203        }
204
205        // A frame with no digest can't be revalidated — §1's D4 makes that a
206        // re-query, not a reuse. Filter before asking, so the request only
207        // carries answerable identities.
208        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            // Correlate by full identity, never by position. A provider that
239            // omits an answer gets `Unknown` — silence is not validity.
240            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    /// Query a single provider by id, honoring the consent gate and the
256    /// per-provider timeout. Querying an unconsented egress provider is
257    /// [`HostError::ConsentRequired`] (legacy boolean) or
258    /// [`HostError::ConsentScopeRequired`] (an off-machine scope with no
259    /// receipt, §3), and the payload is never transmitted (§3.5).
260    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    /// Fan a query out to every capability-matching provider concurrently,
297    /// collecting a per-provider outcome. Each provider is consent-gated,
298    /// timed out, and budget-audited independently — the crash-consistency
299    /// contract means one provider's failure never affects another
300    /// (task deliverables 3 + 5).
301    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    /// Fan a query out under a **global** token budget, splitting it into a
317    /// per-provider `max_tokens` share *before* building each provider's query
318    /// (issue #15). Where [`query_all`](Self::query_all) hands the same
319    /// `max_tokens` to every provider — so N honest providers can each spend the
320    /// whole budget and the honest total is N× the intended prompt budget — this
321    /// gives each capability-matching provider a slice of `global_budget`, so the
322    /// honest legs sum to `<= global_budget`.
323    ///
324    /// `template` supplies every field of the query *except* `max_tokens`, which
325    /// is overwritten per provider with its share from
326    /// [`compose::budget_split`](crate::compose::budget_split) — an equal split
327    /// by default, documented there as swappable for a weighted one. Only
328    /// capability-matching providers (the same filter `query_all` applies) count
329    /// toward the split and receive a query. Each leg is still consent-gated,
330    /// timed out, and budget-audited exactly as in `query_all`, so a provider
331    /// that overspends *its share* is dropped with a report by the existing B2
332    /// audit — the split composes with per-leg honesty rather than replacing it.
333    ///
334    /// [`query_all`](Self::query_all) stays the un-budgeted legacy path.
335    pub async fn query_all_budgeted(&self, template: &ContextQuery, global_budget: u32) -> FanOut {
336        use futures_util::future::join_all;
337
338        // The providers this query would reach — the same capability filter
339        // `query_all` uses, so the split is over exactly the legs that run.
340        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        // Shares are computed once, up front, from the count of matching
348        // providers — before any provider's query is built.
349        let shares = crate::compose::budget_split(global_budget, matching.len());
350
351        // Materialize each provider's query so it outlives the borrowed fan-out
352        // futures below; only `max_tokens` differs from the template.
353        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    /// Run one provider's leg of a fan-out, converting every failure mode into
373    /// a value — never a propagated error that could abort sibling legs.
374    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        // Consent gate first: the query payload itself may carry workspace
382        // content, so it must never reach an unconsented egress provider —
383        // whether gated by the legacy boolean flag or by an unconsented
384        // off-machine egress scope (§3).
385        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        // Budget honesty, axis 1 (§7, B2): frames that sum above the query
426        // budget are a lie about `token_cost`. Drop them, report loudly.
427        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        // Budget honesty, axis 2 (§7, B4): more frames than `max_frames` is an
439        // overspend the token budget never captures — each frame carries a
440        // title, a citation label, and rendering chrome. Symmetric to B2: drop
441        // the whole leg, report it loudly, never silently truncate.
442        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    /// Shut every provider down cleanly, consuming the host so its stdio
459    /// children are reaped as they drop. Returns each provider's shutdown
460    /// result so a caller can log stragglers.
461    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/// The result of fanning one query out across all capability-matching
471/// providers.
472#[derive(Debug)]
473pub struct FanOut {
474    /// One entry per provider that matched the query's frame kinds, in
475    /// registration order.
476    pub outcomes: Vec<ProviderOutcome>,
477}
478
479impl FanOut {
480    /// Every frame from providers that passed the consent gate, the timeout,
481    /// and the budget-honesty audit — the frames a host may honestly compose
482    /// into a prompt.
483    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    /// The summed honest token cost of every accepted frame.
494    pub fn total_accepted_tokens(&self) -> u64 {
495        self.accepted_frames().map(|f| f.token_cost as u64).sum()
496    }
497
498    /// Every accepted frame paired with the id of the provider that served it
499    /// — the input to deterministic composition (`docs/context-reuse.md` §1).
500    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    /// Compose every accepted frame into a byte-stable context block via the
516    /// deterministic composition contract — canonical order, relevance-free
517    /// rendering (`docs/context-reuse.md` §1). Two fan-outs over the same
518    /// frame set compose to identical bytes, so an unchanged turn extends the
519    /// provider's prompt cache instead of busting it.
520    pub fn compose(&self) -> String {
521        crate::compose::compose_context(self.accepted_with_provider())
522    }
523
524    /// Compose every accepted frame into a prompt-ready block via the reference
525    /// composer (issue #15): the R3 evidence preamble, cross-provider-deduped and
526    /// value-ordered fenced frames packed under `global_budget`, a citation map,
527    /// and a [`CompositionAudit`](crate::compose::CompositionAudit) explaining
528    /// every included and excluded frame. Pair with
529    /// [`Host::query_all_budgeted`](crate::Host::query_all_budgeted): the fan-out
530    /// splits the budget across providers, and this packs the survivors under the
531    /// same whole so `audit.tokens_used <= global_budget`.
532    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    /// Roll this fan-out up into a per-request [`UsageReport`] for metering
537    /// (`docs/context-reuse.md` §2). One [`ProviderUsage`] per provider the
538    /// query reached: accepted frames are itemized by stable identity and
539    /// declared cost, a budget-lying provider's dropped frames count as
540    /// rejected, and a failed or consent-gated provider served nothing.
541    ///
542    /// The report is a pure function of this fan-out plus the two host-supplied
543    /// scalars: `budget_requested` is the query's `max_tokens`, and `as_of` is
544    /// the accounting snapshot time (an RFC 3339 string the host stamps — the
545    /// report's own as-of, *not* the query's bi-temporal `as_of` pin). The
546    /// result always satisfies [`UsageReport::is_consistent`]: its totals are
547    /// summed from the same served frames it itemizes.
548    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                    // A budget lie: the provider's frames were dropped whole,
574                    // so nothing was served and every offered frame is rejected.
575                    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                    // A frame flood (§B4): same shape as a budget lie — the
583                    // whole leg was dropped, so every returned frame is rejected
584                    // and nothing was served.
585                    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                    // Consent-gated or failed: no frames offered, none served,
595                    // none rejected — the leg simply contributed nothing.
596                    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    /// Providers that failed (error, timeout, or crash), with their errors.
619    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    /// Providers whose frames were dropped for exceeding the query budget —
629    /// the loud report the host must surface, never swallow (SPEC.md §7, B2).
630    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    /// Providers whose frames were dropped for exceeding `max_frames` — the
637    /// frame-count twin of [`budget_liars`](Self::budget_liars), surfaced
638    /// loudly rather than silently truncated (SPEC.md §7, B4).
639    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/// One provider's outcome within a [`FanOut`].
647#[derive(Debug)]
648pub struct ProviderOutcome {
649    pub provider_id: String,
650    pub result: ProviderResult,
651}
652
653/// What became of one provider's leg of a fan-out — a total function over
654/// every failure mode, so no leg can abort another.
655#[derive(Debug)]
656pub enum ProviderResult {
657    /// Frames the host accepted: passed consent, timeout, and budget honesty.
658    Frames(ContextQueryResult),
659    /// The provider's frames summed above the query budget — a `token_cost`
660    /// lie. Dropped and reported (SPEC.md §7, B2).
661    BudgetLie {
662        claimed_tokens: u64,
663        max_tokens: u32,
664        dropped_frames: usize,
665    },
666    /// The provider returned more frames than `max_frames` — a frame-count
667    /// overspend. Dropped whole and reported, symmetric to a [`BudgetLie`]
668    /// (SPEC.md §7, B4).
669    FrameFlood {
670        returned_frames: usize,
671        max_frames: u32,
672    },
673    /// Skipped: an egress provider (declaring no scopes) without recorded
674    /// boolean consent. The query payload was **not** transmitted (§3.5).
675    ConsentRequired(DataFlow),
676    /// Skipped: the provider declares off-machine egress scope(s) with no
677    /// recorded consent receipt (`docs/context-reuse.md` §3). `missing` names
678    /// the scopes lacking a receipt. The query payload was **not** transmitted.
679    ConsentScopeRequired {
680        data_flow: DataFlow,
681        missing: Vec<EgressScope>,
682    },
683    /// The provider errored, timed out, or crashed mid-query.
684    Failed(HostError),
685}
686
687/// Why a held frame was dropped by [`Host::verify_frames`]
688/// (`docs/context-reuse.md` §4).
689///
690/// The first three mirror the provider's [`Verdict`]s; the rest are host-side
691/// reasons a frame could not be revalidated at all. Either way the frame leaves
692/// the composed context — the difference matters only for deciding whether to
693/// re-query it.
694#[derive(Debug, Clone, PartialEq, Eq)]
695pub enum DropReason {
696    /// The provider answered `stale`: the frame exists but its content changed.
697    /// Carries the provider's current digest when it offered one.
698    Stale { replacement_digest: Option<String> },
699    /// The provider answered `gone` — the frame no longer exists.
700    Gone,
701    /// The provider answered `unknown`, or returned no verdict for the frame at
702    /// all. Silence is not validity.
703    Unknown,
704    /// The frame carries no `content_digest`, so it cannot be revalidated
705    /// (§1, requirement D4).
706    NoDigest,
707    /// The provider does not advertise the `verify` capability, so the host
708    /// falls back to re-querying its frames (§4, requirement V3).
709    VerifyUnsupported,
710    /// No provider with this frame's `provider_id` is registered with the host.
711    UnknownProvider,
712    /// The verify request itself failed — a transport error or a timeout.
713    VerifyFailed(String),
714}
715
716impl DropReason {
717    /// Whether re-querying the provider could recover usable content for this
718    /// frame. False only for [`Gone`](Self::Gone) — every other reason means
719    /// the host simply doesn't have a trustworthy copy and should ask again.
720    pub fn warrants_requery(&self) -> bool {
721        !matches!(self, Self::Gone)
722    }
723}
724
725/// One dropped frame and why (`docs/context-reuse.md` §4).
726#[derive(Debug, Clone, PartialEq, Eq)]
727pub struct DroppedFrame {
728    /// The identity that was dropped.
729    pub frame: FrameId,
730    /// Why it was dropped.
731    pub reason: DropReason,
732}
733
734/// The result of revalidating a held frame set (`docs/context-reuse.md` §4).
735///
736/// Partitions the input into frames the host may keep reusing and frames it
737/// must drop. The partition is **total and default-deny**: every input identity
738/// appears in exactly one of the two lists, and it lands in `retained` only on
739/// an explicit `valid`.
740#[derive(Debug, Clone, Default, PartialEq, Eq)]
741pub struct VerifyOutcome {
742    /// Frames that verified `valid` — safe to keep reusing, and the frames
743    /// whose byte-stable reuse §1's canonical ordering was built to protect.
744    pub retained: Vec<FrameId>,
745    /// Frames that must leave the composed context, each with its reason.
746    pub dropped: Vec<DroppedFrame>,
747}
748
749impl VerifyOutcome {
750    /// The dropped frames worth re-querying — everything except `gone`, which
751    /// is not there to re-fetch.
752    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    /// Whether an identity was dropped.
760    pub fn was_dropped(&self, frame: &FrameId) -> bool {
761        self.dropped.iter().any(|dropped| &dropped.frame == frame)
762    }
763
764    /// The reason an identity was dropped, if it was.
765    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    /// A configurable in-process provider for exercising the router.
794    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        /// A provider declaring egress scopes, for the scope-consent gate.
823        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        // Four honest providers, each returning a frame that fits its equal share
958        // of a 1000-token global budget (250 each). Under the budgeted fan-out
959        // every leg is accepted and the honest total stays under the whole — the
960        // overrun `query_all` allows (each provider spending the full budget) is
961        // closed by allocating shares before fan-out.
962        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(); // max_tokens on the template is ignored by the split
971        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        // A provider returning a 300-token frame against a 1000-token whole split
988        // four ways gets a 250-token share — so its frame is a budget lie against
989        // *its share* and is dropped, even though 300 <= the 1000 global. The
990        // same frame sails through the un-budgeted `query_all` (300 <= 1000),
991        // which is exactly the global overrun the split exists to prevent.
992        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        // Un-budgeted, the same 300-cost frame is within the flat 1000 budget and
1021        // is accepted — the overrun the split closes.
1022        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        // The reference host's deterministic-composition round trip
1029        // (`docs/context-reuse.md` §1): the same frame set, fanned out twice,
1030        // composes to byte-identical bytes — so an unchanged turn extends the
1031        // provider's prompt-cache prefix instead of forfeiting it.
1032        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        // All three frames are present, each fenced exactly once, and the
1051        // lower-sorting provider id renders first regardless of registration
1052        // order.
1053        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        // 1200 tokens against a 1000-token budget: dropped as a budget lie.
1066        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        // The report re-sums from its own itemized frames…
1080        assert!(report.is_consistent());
1081        assert!(report.within_budget());
1082        // …and its consumed total equals an INDEPENDENT re-sum of the accepted
1083        // frames — the arithmetic identity, not a build-then-assert tautology.
1084        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        // Served frames are itemized by stable identity for audit walk-back.
1097        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        // 1200 tokens claimed against a 1000-token budget: a lie.
1120        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        // The liar's frames never reach the accepted set…
1133        assert_eq!(fanout.accepted_frames().count(), 1);
1134        assert_eq!(fanout.total_accepted_tokens(), 200);
1135        // …and the lie is reported loudly, not swallowed.
1136        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        // §B4, the frame-count twin of the budget lie: 12 individually-cheap
1156        // frames respect the token budget but blow `max_frames = 10`. Before
1157        // this audit they sailed straight through, because only the token sum
1158        // was checked.
1159        let query = query();
1160        let flood: Vec<ContextFrame> = (0..12).map(|i| frame(&format!("f{i}"), 1)).collect();
1161        // The token budget alone would have accepted every one of them — the
1162        // frame cap is the only thing that catches this.
1163        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        // The flooder's frames never reach the accepted set; the honest peer's do.
1185        assert_eq!(fanout.accepted_frames().count(), 1);
1186        assert_eq!(fanout.total_accepted_tokens(), 200);
1187
1188        // The overspend is reported loudly, not silently truncated.
1189        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        // A flood is not a budget lie — the two audits are distinct.
1203        assert_eq!(fanout.budget_liars().count(), 0);
1204
1205        // The usage report accounts every flooded frame as rejected, none served.
1206        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        // Without consent: skipped, and — critically — query() never ran, so
1270        // the payload never left.
1271        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        // Direct query is the named error.
1279        assert!(matches!(
1280            host.query_provider("github", &query()).await,
1281            Err(HostError::ConsentRequired { .. })
1282        ));
1283
1284        // After consent: queried and its frames accepted.
1285        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        // Without a receipt: skipped as a scope-consent gap, and the payload
1313        // never left.
1314        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        // Direct query is the scope-specific typed error naming what would leave.
1324        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        // After a receipt for the declared scope: queried and accepted.
1332        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    // ---- context/verify (§4) ----
1354
1355    use contextgraph_types::{FrameVerdict, VerifyResponse};
1356    use std::collections::HashMap as StdHashMap;
1357
1358    /// A provider that answers `context/verify` from a scripted verdict table.
1359    struct VerifyingProvider {
1360        id: String,
1361        capabilities: Capabilities,
1362        /// frame id -> verdict. A frame absent from the table gets no verdict
1363        /// entry at all, exercising the "silence is not validity" path.
1364        verdicts: StdHashMap<String, Verdict>,
1365        /// When set, `verify` fails instead of answering.
1366        verify_error: Option<String>,
1367        /// Identities this provider was actually asked about.
1368        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            // Local-only: nothing here is about consent.
1405            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        // The core §4 guarantee: the host demonstrably evicts a frame the
1465        // provider says has changed, and keeps the one it vouches for.
1466        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        // A stale frame is worth re-fetching; the replacement digest tells the
1494        // host what it would be getting.
1495        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        // Nothing to re-fetch — `gone` is the one reason that doesn't warrant it.
1512        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        // Silence is not validity: a provider that omits an answer must not
1518        // have that read as "still good".
1519        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        // The capability gate (V3): the host doesn't send a verify request at
1540        // all, it just re-queries.
1541        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        // §1 D4: no digest, no revalidation — and the request only carries
1561        // answerable identities.
1562        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        // Per-provider isolation, same contract as a query fan-out leg.
1583        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        // Verification costs bytes, not tokens — so it must not cost a round
1618        // trip per frame either.
1619        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        // Both identities arrived together in a single verify call.
1636        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}