Skip to main content

lean_ctx/core/context_admission/
capture.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Request-scoped collection of admission decisions and the one Decision
3//! Receipt per tool call built from them (G4).
4//!
5//! The MCP dispatcher opens a capture around each call; the read choke point
6//! appends one content-free decision per admitted object, including from the
7//! detached read workers (`TaskSpine::spawn_thread` carries the capture). When
8//! the call's final result exists, `finish` turns the decisions into a
9//! validated `ContextDecisionReceiptV1` whose digest binds exactly the bytes
10//! that were returned. Nothing here holds or persists content.
11
12use std::sync::{Arc, Mutex, PoisonError};
13use std::time::Instant;
14
15use lean_ctx_protocol::context_gateway::{
16    ContextDecisionReceiptV1, ContextDecisionV1, ContextDispositionV1, DeliveryOutcomeV1,
17    DestinationLocalityV1, DestinationV1, GatewayModeV1, MAX_DECISIONS, PolicyRefV1,
18    PrincipalKindV1, PrincipalV1, RECEIPT_V2_SCHEMA_VERSION, ReasonCodeV1, SecurityCountsV1,
19    SourceCountsV1, TokenAccountV1,
20};
21use lean_ctx_protocol::{PolicyId, ProtocolReference, Sha256Digest, TaskId};
22
23use super::AdmissionCounts;
24
25tokio::task_local! {
26    pub static ADMISSIONS: Option<Arc<AdmissionCapture>>;
27}
28
29/// The capture of the call running on this task or worker, if any.
30pub fn current() -> Option<Arc<AdmissionCapture>> {
31    ADMISSIONS.try_with(Clone::clone).ok().flatten()
32}
33
34#[derive(Debug, Clone)]
35struct Recorded {
36    decision: ContextDecisionV1,
37    counts: AdmissionCounts,
38}
39
40/// The policy snapshot of ONE call (G08), taken once at its entry — where the
41/// MCP dispatcher opens the call's capture, where a CLI run starts — and
42/// carried by the capture: the gateway rules, whether protection is active
43/// for the request's project, and the active policy pack. While the capture
44/// is open, `carrier::current_policy` returns these rules, so the release
45/// point, the receipt duty, the trace sidecar and the stored-copy checks of
46/// the call decide under the rules of its entry, whatever is configured by
47/// the time they run.
48#[derive(Debug, Clone)]
49pub struct CallSnapshot {
50    admission: super::stores::StoreAdmission,
51    protected: bool,
52    pack: Option<(String, String)>,
53    /// The organization decision the verifier stated in the verification the
54    /// pack above comes from; `None` when no verifier was asked.
55    org_policy: Option<OrgObservation>,
56}
57
58/// See [`crate::core::policy::org::Observation`].
59pub(crate) type OrgObservation = crate::core::policy::org::Observation;
60
61/// The organization decision a capture's admissions were made under.
62#[derive(Debug, Clone, PartialEq, Eq, Default)]
63pub enum OrgEvidence {
64    /// No verifier decision was observed.
65    #[default]
66    None,
67    /// Every admission of the capture rests on this one observation.
68    One(OrgObservation),
69    /// Admissions that were merged into one capture rest on different
70    /// observations (or one of them on none). No single organization decision
71    /// can be named for the delivery, and none is: the record says so instead
72    /// of naming the first or the last.
73    Diverged,
74    /// The admission ran where no verified view was held to take an
75    /// observation from (an opaque body admitted outside a source view). It
76    /// did not ask a verifier of its own; the record says that no
77    /// organization decision is available for it — not that there is none.
78    Unavailable,
79}
80
81impl OrgEvidence {
82    fn of(observation: Option<OrgObservation>) -> Self {
83        observation.map_or(Self::None, Self::One)
84    }
85}
86
87impl CallSnapshot {
88    /// Take the snapshot now. Call inside the request scope: protection and
89    /// the pack are those of the request's project. Inside an open capture
90    /// that carries a snapshot (a run that is part of a call) the gateway
91    /// rules are that call's.
92    pub fn take() -> Self {
93        let admission = super::carrier::gateway_snapshot();
94        let protected =
95            super::stored::source_authority_required_under(admission.admission_policy());
96        // One read: the pack and the organization observation are those of
97        // the same verified policy.
98        let (active, org_policy) = crate::core::policy::runtime::active_observed();
99        let pack = active.map(|active| {
100            (
101                active.resolved.name.clone(),
102                active.resolved.version.clone(),
103            )
104        });
105        Self {
106            admission,
107            protected,
108            pack,
109            org_policy,
110        }
111    }
112
113    /// The gateway rules of the call.
114    pub fn admission(&self) -> &super::stores::StoreAdmission {
115        &self.admission
116    }
117
118    /// Protection was active at the call's entry (a project policy pack, the
119    /// gateway in governed or sovereign mode, an unloadable configuration).
120    pub(crate) fn protected(&self) -> bool {
121        self.protected
122    }
123
124    /// The organization decision observed with the pack at the call's entry.
125    pub(crate) fn org_policy(&self) -> OrgEvidence {
126        OrgEvidence::of(self.org_policy.clone())
127    }
128
129    /// Name and version of the policy pack active at the call's entry.
130    pub(crate) fn pack(&self) -> Option<&(String, String)> {
131        self.pack.as_ref()
132    }
133}
134
135/// Source references one capture holds for its trace sidecar.
136pub(crate) const MAX_SOURCE_REFERENCES: usize = 64;
137/// Longest source reference recorded; a longer one is counted, not recorded.
138pub(crate) const MAX_SOURCE_REFERENCE_BYTES: usize = 256;
139
140/// The name a reader showed for a source it delivered.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum SourceName {
143    /// A project file: its path relative to the project.
144    Path(String),
145    /// Not a project file: the id it has at its origin (the path in a
146    /// remote repository, a provider's source id).
147    Id(String),
148}
149
150/// What the admitted readers named of the sources this call admitted.
151#[derive(Debug, Default)]
152struct SourceNotes {
153    /// Object digest of an itemized decision → the name its reader
154    /// delivered it under.
155    named: Vec<(Sha256Digest, SourceName)>,
156    /// References that were not recorded (bound reached, not expressible).
157    omitted: u32,
158}
159
160thread_local! {
161    /// The delivering source decision this thread recorded last and no reader
162    /// has named yet: the capture it belongs to and the decision's object.
163    /// An admitted reader admits a source and publishes it on one thread, in
164    /// that order; [`note_source`] takes the entry, so one decision is named
165    /// at most once and a publication without a decision names nothing.
166    static UNNAMED: std::cell::RefCell<Option<(usize, Sha256Digest)>> =
167        const { std::cell::RefCell::new(None) };
168}
169
170/// Name the source the calling thread admitted last (G08): the reference the
171/// reader shows for it — its path relative to `project`. Called by the
172/// admitted readers' publication (`core::archive::authority::publish_read`),
173/// i.e. only for a source that was delivered: a withheld source is never
174/// named here, and the sidecar names it as withheld, as the reader does. A
175/// path outside the project, not UTF-8 or longer than the bound is counted,
176/// not recorded. No capture: nothing happens.
177pub(crate) fn note_source(project: &std::path::Path, canonical_path: &str) {
178    let Some((capture, object)) = unnamed_decision() else {
179        return;
180    };
181    let path = std::path::Path::new(canonical_path);
182    let relative = path
183        .strip_prefix(project)
184        .ok()
185        .map(std::path::Path::to_path_buf)
186        .or_else(|| {
187            let project = crate::core::pathutil::canonicalize_secure(project).ok()?;
188            path.strip_prefix(&project)
189                .ok()
190                .map(std::path::Path::to_path_buf)
191        });
192    let reference = relative
193        .as_deref()
194        .and_then(std::path::Path::to_str)
195        .filter(|reference| !reference.is_empty() && reference.len() <= MAX_SOURCE_REFERENCE_BYTES)
196        .map(|reference| SourceName::Path(reference.replace('\\', "/")));
197    capture.name_source(object, reference);
198}
199
200/// `note_source` for a source that is not a project file (G08): `id` is
201/// what its reader shows for it — the path it has at its origin, a
202/// provider's source id. Called by the reader right after it admitted the
203/// source, on the same thread, and only for a source it delivers. The same
204/// bounds apply; an empty or over-long id is counted, not recorded. The
205/// sidecar keeps it only if it passes the release check as a word.
206pub fn note_source_id(id: &str) {
207    let Some((capture, object)) = unnamed_decision() else {
208        return;
209    };
210    let reference = (!id.is_empty() && id.len() <= MAX_SOURCE_REFERENCE_BYTES)
211        .then(|| SourceName::Id(id.to_owned()));
212    capture.name_source(object, reference);
213}
214
215/// The open capture and the object of the delivering decision the calling
216/// thread recorded into it last and that no reader has named yet. Taking it
217/// spends it.
218fn unnamed_decision() -> Option<(Arc<AdmissionCapture>, Sha256Digest)> {
219    let capture = current()?;
220    let owner = std::ptr::from_ref::<AdmissionCapture>(&capture).addr();
221    let (recorded_by, object) = UNNAMED.with(|slot| slot.borrow_mut().take())?;
222    (recorded_by == owner).then_some((capture, object))
223}
224
225/// Everything one call contributes to its receipt.
226#[derive(Debug)]
227pub struct AdmissionCapture {
228    started: Instant,
229    records: Mutex<Vec<Recorded>>,
230    /// Decisions beyond `MAX_DECISIONS` are counted in full, never dropped.
231    overflow: Mutex<Overflow>,
232    /// Original tokens the tool measured for its sources (pre-optimization).
233    original_tokens: Mutex<Option<u64>>,
234    policy: Mutex<Option<(GatewayModeV1, Sha256Digest)>>,
235    /// The task the handler ran under and its tenant/project scope, bound
236    /// where admission happens.
237    task: Mutex<Option<(String, String)>>,
238    /// The call's policy snapshot, when the capture was opened for a call
239    /// ([`Self::for_call`]). A proxy egress capture has none: its admission
240    /// holds its own snapshot.
241    snapshot: Option<CallSnapshot>,
242    /// An admission of this call was decided under other gateway rules than
243    /// the call's snapshot.
244    diverged: std::sync::atomic::AtomicBool,
245    sources: Mutex<SourceNotes>,
246    /// Name and version of the policy pack an egress admission resolved.
247    pack: Mutex<Option<(String, String)>>,
248    /// The organization decision an egress admission's own snapshot observed
249    /// (`None`: nothing noted yet).
250    org_policy: Mutex<Option<OrgEvidence>>,
251}
252
253impl Default for AdmissionCapture {
254    fn default() -> Self {
255        Self {
256            started: Instant::now(),
257            records: Mutex::new(Vec::new()),
258            overflow: Mutex::new(Overflow::default()),
259            original_tokens: Mutex::new(None),
260            policy: Mutex::new(None),
261            task: Mutex::new(None),
262            snapshot: None,
263            diverged: std::sync::atomic::AtomicBool::new(false),
264            sources: Mutex::new(SourceNotes::default()),
265            pack: Mutex::new(None),
266            org_policy: Mutex::new(None),
267        }
268    }
269}
270
271impl AdmissionCapture {
272    pub fn new() -> Arc<Self> {
273        Arc::new(Self::default())
274    }
275
276    /// The capture of one call, carrying the policy snapshot taken at the
277    /// call's entry.
278    pub fn for_call(snapshot: CallSnapshot) -> Arc<Self> {
279        Arc::new(Self {
280            snapshot: Some(snapshot),
281            ..Self::default()
282        })
283    }
284
285    /// The call's policy snapshot, when this capture was opened for a call.
286    pub fn snapshot(&self) -> Option<&CallSnapshot> {
287        self.snapshot.as_ref()
288    }
289
290    /// Whether an admission recorded here was decided under other gateway
291    /// rules than the call's snapshot. Always `false` without a snapshot.
292    pub(crate) fn policy_diverged(&self) -> bool {
293        self.diverged.load(std::sync::atomic::Ordering::SeqCst)
294    }
295
296    /// The references the admitted readers named, by the object digest of
297    /// the decision each belongs to, and how many were not recorded.
298    pub fn source_references(&self) -> (Vec<(Sha256Digest, SourceName)>, u32) {
299        let sources = self.sources.lock().unwrap_or_else(PoisonError::into_inner);
300        (sources.named.clone(), sources.omitted)
301    }
302
303    /// Record the name of the decision about `object`, or count it as not
304    /// recorded (`None`, or the bound is reached).
305    fn name_source(&self, object: Sha256Digest, name: Option<SourceName>) {
306        let mut sources = self.sources.lock().unwrap_or_else(PoisonError::into_inner);
307        match name {
308            Some(name) if sources.named.len() < MAX_SOURCE_REFERENCES => {
309                sources.named.push((object, name));
310            }
311            _ => sources.omitted = sources.omitted.saturating_add(1),
312        }
313    }
314
315    /// The name of the policy pack the egress admission of this capture
316    /// resolved (proxy), for its trace sidecar.
317    pub(super) fn note_pack(&self, name: &str, version: &str) {
318        *self.pack.lock().unwrap_or_else(PoisonError::into_inner) =
319            Some((name.to_owned(), version.to_owned()));
320    }
321
322    /// The organization decision the egress admission of this capture
323    /// observed in its own snapshot — noted once per admission, also when it
324    /// observed none. A second note that differs makes the evidence
325    /// [`OrgEvidence::Diverged`]; it never replaces the first.
326    pub(super) fn note_org_policy(&self, observation: Option<OrgObservation>) {
327        self.merge_org_policy(OrgEvidence::of(observation));
328    }
329
330    /// The egress admission of this capture held no verified view to take an
331    /// observation from, and asked for none.
332    pub(super) fn note_org_policy_unavailable(&self) {
333        self.merge_org_policy(OrgEvidence::Unavailable);
334    }
335
336    fn merge_org_policy(&self, incoming: OrgEvidence) {
337        let mut noted = self
338            .org_policy
339            .lock()
340            .unwrap_or_else(PoisonError::into_inner);
341        *noted = Some(match noted.take() {
342            None => incoming,
343            Some(present) if present == incoming => present,
344            Some(_) => OrgEvidence::Diverged,
345        });
346    }
347
348    /// The organization decision of the capture: the call snapshot's, or
349    /// what its egress admissions noted.
350    pub(crate) fn org_policy(&self) -> OrgEvidence {
351        match &self.snapshot {
352            Some(snapshot) => OrgEvidence::of(snapshot.org_policy.clone()),
353            None => self
354                .org_policy
355                .lock()
356                .unwrap_or_else(PoisonError::into_inner)
357                .clone()
358                .unwrap_or_default(),
359        }
360    }
361
362    /// The pack of the capture: the call snapshot's, or the one an egress
363    /// admission noted.
364    pub(crate) fn pack(&self) -> Option<(String, String)> {
365        self.snapshot
366            .as_ref()
367            .and_then(|snapshot| snapshot.pack.clone())
368            .or_else(|| {
369                self.pack
370                    .lock()
371                    .unwrap_or_else(PoisonError::into_inner)
372                    .clone()
373            })
374    }
375
376    pub(super) fn record(&self, decision: &ContextDecisionV1, counts: AdmissionCounts) {
377        if let Some(envelope) = crate::core::task_spine::TaskSpine::current() {
378            let mut task = self.task.lock().unwrap_or_else(PoisonError::into_inner);
379            if task.is_none() {
380                *task = Some((
381                    envelope.task_id.as_str().to_owned(),
382                    crate::core::context_store::task_scope(
383                        envelope.tenant_id.as_ref(),
384                        &envelope.project_id,
385                    ),
386                ));
387            }
388        }
389        // The reader that admitted this source may name it next, on this
390        // thread ([`note_source`]); a note or a withheld source is not named.
391        let nameable = decision.object != note_object() && decision.disposition.delivers_content();
392        UNNAMED.with(|slot| {
393            *slot.borrow_mut() =
394                nameable.then(|| (std::ptr::from_ref(self).addr(), decision.object.clone()));
395        });
396        let incoming = Recorded {
397            decision: decision.clone(),
398            counts,
399        };
400        let mut records = self.records.lock().unwrap_or_else(PoisonError::into_inner);
401        if records.len() < MAX_DECISIONS {
402            records.push(incoming);
403            return;
404        }
405        // The receipt holds at most MAX_DECISIONS decisions. A notable one
406        // (changed, withheld, flagged) displaces a clean one, so every
407        // decision the receipt is about stays itemized; whatever is not
408        // itemized still counts in full.
409        let displaced = if incoming.is_clean() {
410            incoming
411        } else if let Some(position) = records.iter().position(Recorded::is_clean) {
412            std::mem::replace(&mut records[position], incoming)
413        } else if incoming.is_note() {
414            // A note adds to no count, so one that finds no slot would be
415            // gone without a trace. The receipt says so instead: one fixed
416            // note stands for every note it does not itemize.
417            let Some(displaced) = mark_notes_not_itemized(&mut records) else {
418                return;
419            };
420            displaced
421        } else {
422            incoming
423        };
424        self.overflow
425            .lock()
426            .unwrap_or_else(PoisonError::into_inner)
427            .add(&displaced);
428    }
429
430    pub(super) fn bind_policy(&self, mode: GatewayModeV1, digest: Sha256Digest) {
431        if let Some(snapshot) = &self.snapshot
432            && snapshot.admission.digest() != digest.hex()
433        {
434            self.diverged
435                .store(true, std::sync::atomic::Ordering::SeqCst);
436        }
437        *self.policy.lock().unwrap_or_else(PoisonError::into_inner) = Some((mode, digest));
438    }
439
440    /// The tenant/project scope of the call's task, for its receipt index.
441    pub(crate) fn task_scope(&self) -> Option<String> {
442        self.task
443            .lock()
444            .unwrap_or_else(PoisonError::into_inner)
445            .as_ref()
446            .map(|(_, scope)| scope.clone())
447    }
448
449    /// The measured original token count reported by the tool for this call.
450    pub fn set_original_tokens(&self, tokens: u64) {
451        *self
452            .original_tokens
453            .lock()
454            .unwrap_or_else(PoisonError::into_inner) = Some(tokens);
455    }
456
457    pub fn is_empty(&self) -> bool {
458        self.records
459            .lock()
460            .unwrap_or_else(PoisonError::into_inner)
461            .is_empty()
462    }
463
464    /// Whether the capture holds a decision about a source, itemized or only
465    /// counted: then the delivery owes a receipt. Notes alone describe no
466    /// source — the rule by which [`finish_for_presenter`] returns `None`.
467    pub(crate) fn describes_sources(&self) -> bool {
468        let note = note_object();
469        let itemized = self
470            .records
471            .lock()
472            .unwrap_or_else(PoisonError::into_inner)
473            .iter()
474            .any(|record| record.decision.object != note);
475        itemized
476            || self
477                .overflow
478                .lock()
479                .unwrap_or_else(PoisonError::into_inner)
480                .objects
481                > 0
482    }
483
484    /// Make a delivery that carries bytes but no admitted source decision a
485    /// receipted one (G08): the delivered output itself becomes the one
486    /// itemized object, permitted, with the fixed reason
487    /// [`NO_SOURCE_DECISION`]. The receipt then says exactly that — bytes
488    /// left without a source decision — instead of there being no receipt.
489    /// A capture no admission bound is bound to `mode` and `policy`.
490    pub(super) fn record_unsourced(
491        &self,
492        output: Sha256Digest,
493        mode: GatewayModeV1,
494        policy: Sha256Digest,
495    ) {
496        if self.bound_policy().is_none() {
497            self.bind_policy(mode, policy);
498        }
499        let decision = ContextDecisionV1 {
500            object: output,
501            disposition: ContextDispositionV1::Allow,
502            reason_codes: vec![
503                ReasonCodeV1::new(NO_SOURCE_DECISION).expect("built-in reason codes are valid"),
504            ],
505            signals: Vec::new(),
506            required_transformations: Vec::new(),
507        };
508        self.record(&decision, AdmissionCounts::default());
509    }
510
511    /// The itemized decisions with the counts admission measured for each,
512    /// for the trace sidecar: what a V1 receipt holds only as totals.
513    pub fn itemized(&self) -> Vec<(ContextDecisionV1, AdmissionCounts)> {
514        self.records
515            .lock()
516            .unwrap_or_else(PoisonError::into_inner)
517            .iter()
518            .map(|record| (record.decision.clone(), record.counts))
519            .collect()
520    }
521
522    /// Sources the receipt counts without itemizing them.
523    pub(crate) fn counted_only(&self) -> u32 {
524        self.overflow
525            .lock()
526            .unwrap_or_else(PoisonError::into_inner)
527            .objects
528    }
529
530    /// The mode and policy digest this capture is bound to, if any.
531    pub(super) fn bound_policy(&self) -> Option<(GatewayModeV1, Sha256Digest)> {
532        self.policy
533            .lock()
534            .unwrap_or_else(PoisonError::into_inner)
535            .clone()
536    }
537
538    /// Add every decision of `earlier` to this capture, as if it had been
539    /// recorded here: itemized ones through [`Self::record`] (so the
540    /// `MAX_DECISIONS` rule holds for the union), counted-only ones into the
541    /// overflow totals. Policy binding and task of `self` are kept.
542    pub(super) fn absorb(&self, earlier: &Self) {
543        if let Some((name, version)) = earlier.pack()
544            && self.pack().is_none()
545        {
546            self.note_pack(&name, &version);
547        }
548        // Unlike the pack word, the organization decision is evidence: what
549        // an earlier admission noted is merged, and a difference is recorded
550        // as one. A capture that noted nothing contributes nothing.
551        let theirs = earlier
552            .org_policy
553            .lock()
554            .unwrap_or_else(PoisonError::into_inner)
555            .clone();
556        if let Some(theirs) = theirs {
557            self.merge_org_policy(theirs);
558        }
559        let records = earlier
560            .records
561            .lock()
562            .unwrap_or_else(PoisonError::into_inner)
563            .clone();
564        for record in &records {
565            self.record(&record.decision, record.counts);
566        }
567        let theirs = earlier
568            .overflow
569            .lock()
570            .unwrap_or_else(PoisonError::into_inner);
571        let mut ours = self.overflow.lock().unwrap_or_else(PoisonError::into_inner);
572        ours.objects = ours.objects.saturating_add(theirs.objects);
573        ours.permitted = ours.permitted.saturating_add(theirs.permitted);
574        ours.blocked = ours.blocked.saturating_add(theirs.blocked);
575        ours.redactions = ours.redactions.saturating_add(theirs.redactions);
576        ours.injection = ours.injection.saturating_add(theirs.injection);
577        ours.incomplete = ours.incomplete.saturating_add(theirs.incomplete);
578        ours.security.merge(&theirs.security);
579        ours.injection_flags
580            .extend(theirs.injection_flags.iter().cloned());
581    }
582
583    /// Reason codes of every itemized deny decision, in order, without
584    /// duplicates: what a refusal is made of.
585    pub(super) fn deny_reason_codes(
586        &self,
587    ) -> Vec<lean_ctx_protocol::context_gateway::ReasonCodeV1> {
588        let mut codes = Vec::new();
589        for record in self
590            .records
591            .lock()
592            .unwrap_or_else(PoisonError::into_inner)
593            .iter()
594            .filter(|record| record.decision.disposition == ContextDispositionV1::Deny)
595        {
596            for code in &record.decision.reason_codes {
597                if !codes.contains(code) {
598                    codes.push(code.clone());
599                }
600            }
601        }
602        codes
603    }
604
605    /// Whether an admitted source carried prompt-injection signals; the
606    /// dispatcher's own output scan must then not count the same text again.
607    pub fn flagged_injection(&self) -> bool {
608        self.records
609            .lock()
610            .unwrap_or_else(PoisonError::into_inner)
611            .iter()
612            .any(|record| record.counts.injection > 0)
613    }
614
615    /// The call's security actions, in the event model the status line and
616    /// `lean-ctx value` prove from the audit trail. Only what actually
617    /// happened counts: a detected-but-delivered secret is not "kept out".
618    pub(crate) fn security_tally(&self) -> crate::core::security_events::SecurityCounts {
619        let mut tally = self
620            .overflow
621            .lock()
622            .unwrap_or_else(PoisonError::into_inner)
623            .security;
624        for record in self
625            .records
626            .lock()
627            .unwrap_or_else(PoisonError::into_inner)
628            .iter()
629        {
630            tally.merge(&record.security());
631        }
632        tally
633    }
634
635    /// Every object this call flagged for prompt injection, itemized or not.
636    fn injection_flags(&self) -> Vec<crate::core::security_events::InjectionFlag> {
637        let mut flags = self
638            .overflow
639            .lock()
640            .unwrap_or_else(PoisonError::into_inner)
641            .injection_flags
642            .clone();
643        flags.extend(
644            self.records
645                .lock()
646                .unwrap_or_else(PoisonError::into_inner)
647                .iter()
648                .filter_map(Recorded::injection_flag),
649        );
650        flags
651    }
652
653    /// Records the call's security actions once: the audit trail and the
654    /// session counters. A flagged object counts only the first time this
655    /// process delivers it (the receipt still carries every delivery's
656    /// signal). Returns what was recorded.
657    pub(crate) fn record_security(
658        &self,
659        tool: &str,
660        agent_id: &str,
661    ) -> crate::core::security_events::SecurityCounts {
662        use crate::core::security_events::{self, SecurityKind};
663        let mut tally = self.security_tally();
664        tally.injection_flagged = 0;
665        security_events::record(tool, agent_id, &tally);
666        let flagged = security_events::record_injections(tool, agent_id, &self.injection_flags());
667        tally.add(SecurityKind::InjectionFlagged, flagged);
668        tally
669    }
670}
671
672/// The code of the one note that stands for every note a full receipt could
673/// not itemize. Fixed and content-free; it carries no number — how many
674/// blocks stayed whole is then stated by the response header and the log
675/// line of the request, not by the receipt.
676pub(super) const NOTES_NOT_ITEMIZED: &str = "recovery.notes_not_itemized";
677
678/// The reason of the one decision a receipt holds for a delivery whose bytes
679/// no admission decided on (an empty or source-less capture under
680/// protection, e.g. output with unknown lineage). Fixed and content-free.
681pub(crate) const NO_SOURCE_DECISION: &str = "delivery.no_source_decision";
682
683/// A note found every slot of a full receipt taken by decisions the receipt
684/// is about. Make the receipt say that notes are missing: unless it already
685/// does, the fixed [`NOTES_NOT_ITEMIZED`] note takes the slot of an itemized
686/// note — which it then stands for as well — or, where no note is itemized,
687/// the slot of a source decision (one that delivered content, if there is
688/// one). Returns that source decision: the caller counts it in full, as it
689/// counts every decision that is not itemized. `None`: no count moves.
690fn mark_notes_not_itemized(records: &mut [Recorded]) -> Option<Recorded> {
691    if records.iter().any(Recorded::states_notes_not_itemized) {
692        return None;
693    }
694    let marker = Recorded {
695        decision: ContextDecisionV1 {
696            object: note_object(),
697            disposition: ContextDispositionV1::Allow,
698            reason_codes: vec![
699                ReasonCodeV1::new(NOTES_NOT_ITEMIZED).expect("built-in reason codes are valid"),
700            ],
701            signals: Vec::new(),
702            required_transformations: Vec::new(),
703        },
704        counts: AdmissionCounts::default(),
705    };
706    let slot = records
707        .iter()
708        .rposition(Recorded::is_note)
709        .or_else(|| {
710            records
711                .iter()
712                .rposition(|record| record.decision.disposition.delivers_content())
713        })
714        .unwrap_or(records.len().saturating_sub(1));
715    let displaced = std::mem::replace(records.get_mut(slot)?, marker);
716    (!displaced.is_note()).then_some(displaced)
717}
718
719impl Recorded {
720    fn injection_flag(&self) -> Option<crate::core::security_events::InjectionFlag> {
721        (self.counts.injection > 0).then(|| crate::core::security_events::InjectionFlag {
722            object_sha256: self.decision.object.hex().to_owned(),
723            patterns: self.counts.injection_patterns,
724        })
725    }
726
727    /// Delivered unchanged, nothing flagged, fully inspected.
728    fn is_clean(&self) -> bool {
729        self.decision.disposition == ContextDispositionV1::Allow
730            && self.decision.reason_codes.is_empty()
731    }
732
733    /// A statement about a source, not a source ([`note_object`]).
734    fn is_note(&self) -> bool {
735        self.decision.object == note_object()
736    }
737
738    fn states_notes_not_itemized(&self) -> bool {
739        self.is_note()
740            && self
741                .decision
742                .reason_codes
743                .iter()
744                .any(|reason| reason.as_str() == NOTES_NOT_ITEMIZED)
745    }
746
747    /// This decision's security actions. Only what actually happened counts:
748    /// a detected-but-delivered secret is not "kept out".
749    fn security(&self) -> crate::core::security_events::SecurityCounts {
750        use crate::core::security_events::{SecurityCounts, SecurityKind};
751        let mut tally = SecurityCounts::default();
752        let has = |code: &str| {
753            self.decision
754                .reason_codes
755                .iter()
756                .any(|reason| reason.as_str() == code)
757        };
758        if has("secret.redacted") {
759            tally.add(SecurityKind::SecretRedacted, u64::from(self.counts.secrets));
760        }
761        if has("pii.redacted") {
762            tally.add(SecurityKind::PiiRedacted, u64::from(self.counts.pii));
763        }
764        if self.counts.injection > 0 {
765            tally.add(
766                SecurityKind::InjectionFlagged,
767                u64::from(self.counts.injection),
768            );
769        }
770        if self.decision.disposition == ContextDispositionV1::Deny {
771            tally.add(SecurityKind::ContentWithheld, 1);
772        } else if self.counts.incomplete > 0 {
773            tally.add(SecurityKind::CoverageIncomplete, 1);
774        }
775        tally
776    }
777
778    fn redactions(&self) -> u32 {
779        if self.decision.disposition == ContextDispositionV1::AllowRedacted {
780            self.counts.secrets.saturating_add(self.counts.pii)
781        } else {
782            0
783        }
784    }
785}
786
787/// Everything about the decisions a receipt counts but does not itemize.
788#[derive(Debug, Default)]
789struct Overflow {
790    objects: u32,
791    permitted: u32,
792    blocked: u32,
793    redactions: u32,
794    injection: u32,
795    incomplete: u32,
796    security: crate::core::security_events::SecurityCounts,
797    injection_flags: Vec<crate::core::security_events::InjectionFlag>,
798}
799
800impl Overflow {
801    fn add(&mut self, record: &Recorded) {
802        // A note is a statement about a source, not a source: where it cannot
803        // be itemized it adds to no source count.
804        if record.decision.object == note_object() {
805            return;
806        }
807        let disposition = record.decision.disposition;
808        self.objects = self.objects.saturating_add(1);
809        if disposition.delivers_content() {
810            self.permitted = self.permitted.saturating_add(1);
811        }
812        if disposition == ContextDispositionV1::Deny {
813            self.blocked = self.blocked.saturating_add(1);
814        }
815        self.redactions = self.redactions.saturating_add(record.redactions());
816        self.injection = self.injection.saturating_add(record.counts.injection);
817        if record.counts.incomplete > 0 {
818            self.incomplete = self.incomplete.saturating_add(1);
819        }
820        self.security.merge(&record.security());
821        self.injection_flags.extend(record.injection_flag());
822    }
823}
824
825/// Identity of the call the receipt describes.
826pub struct CallIdentity<'a> {
827    pub agent_id: Option<&'a str>,
828    /// Where the context goes, when the caller knows it (the proxy does).
829    pub destination: Option<DestinationV1>,
830}
831
832/// The delivered result as the host receives it.
833pub struct Delivered<'a> {
834    pub text: &'a str,
835    pub is_error: bool,
836    pub tokens: u64,
837}
838
839fn reference(prefix: &str, raw: &str) -> Option<ProtocolReference> {
840    // Opaque references must stay bounded and printable; anything else is
841    // reduced to a digest rather than guessed at.
842    ProtocolReference::new(format!("{prefix}{raw}"))
843        .ok()
844        .or_else(|| {
845            let digest = blake3::hash(raw.as_bytes()).to_hex();
846            ProtocolReference::new(format!("{prefix}{}", &digest[..32])).ok()
847        })
848}
849
850fn sha256(bytes: &[u8]) -> Sha256Digest {
851    use sha2::{Digest, Sha256};
852    let hex = crate::core::agent_identity::hex_encode(&Sha256::digest(bytes));
853    Sha256Digest::new(format!("sha256:{hex}")).expect("SHA-256 digest is canonical")
854}
855
856fn saturating_u32(n: usize) -> u32 {
857    u32::try_from(n).unwrap_or(u32::MAX)
858}
859
860/// The fixed object of a note decision: an itemized, content-free statement
861/// about a source the same receipt already counts (the proxy's "this block
862/// stayed whole, and why"). A constant, so a note names no content, and the
863/// one mark by which [`finish`] keeps notes out of the source counts — the
864/// block a note is about was inspected and permitted as its own decision.
865pub(super) fn note_object() -> Sha256Digest {
866    sha256(b"proxy-block-kept-whole")
867}
868
869/// Whether `decision` is a note — a statement about a source the receipt
870/// already counts — and not a source of its own. For readers of receipts.
871pub fn is_note(decision: &ContextDecisionV1) -> bool {
872    let note = note_object();
873    // A version 2 receipt names the note keyed under this installation's key.
874    decision.object == note
875        || super::receipt_store::keyed::object_key()
876            .is_ok_and(|key| decision.object == key.object(&note))
877}
878
879/// Build the call's receipt, or `None` when no governed source was admitted.
880/// The receipt is validated before it is returned; an inconsistent receipt is
881/// never produced. Product callers use `finish_for_presenter`; this form
882/// without a presenter is what the tests call.
883#[cfg(any(test, feature = "test-support"))]
884pub fn finish(
885    capture: &AdmissionCapture,
886    identity: &CallIdentity<'_>,
887    delivered: &Delivered<'_>,
888) -> Option<ContextDecisionReceiptV1> {
889    finish_for_presenter(capture, identity, None, delivered)
890}
891
892/// [`finish`] for a call that showed a live presenter capability
893/// (`core::archive::presented`, "Presenters"). `presenter_ref` is the
894/// content-free reference of its presenter entry — a digest of a stored
895/// verifier, never of the capability — or `None`.
896///
897/// With a reference the receipt's principal is of kind `Session` with that
898/// reference as its id: it says "the holder of this session capability", so
899/// that receipts of one presenter entry can be tied together. It is never
900/// kind `Agent`: a capability names no agent and attests no identity. A
901/// host-named `agent_id` is then not recorded as the principal.
902pub(crate) fn finish_for_presenter(
903    capture: &AdmissionCapture,
904    identity: &CallIdentity<'_>,
905    presenter_ref: Option<&str>,
906    delivered: &Delivered<'_>,
907) -> Option<ContextDecisionReceiptV1> {
908    let records = capture
909        .records
910        .lock()
911        .unwrap_or_else(PoisonError::into_inner)
912        .clone();
913    if records.is_empty() {
914        return None;
915    }
916    let overflow = capture
917        .overflow
918        .lock()
919        .unwrap_or_else(PoisonError::into_inner);
920    let (mode, policy_digest) = capture
921        .policy
922        .lock()
923        .unwrap_or_else(PoisonError::into_inner)
924        .clone()?;
925
926    let decisions: Vec<ContextDecisionV1> = records
927        .iter()
928        .map(|record| record.decision.clone())
929        .collect();
930    // Notes are itemized but are no sources: they count neither as inspected
931    // nor as permitted or selected. A capture that holds nothing but notes
932    // describes no admitted source, exactly like an empty one.
933    let note = note_object();
934    let notes = decisions
935        .iter()
936        .filter(|decision| decision.object == note)
937        .count();
938    if notes == decisions.len() && overflow.objects == 0 {
939        return None;
940    }
941    let count = |disposition: ContextDispositionV1| {
942        decisions
943            .iter()
944            .filter(|decision| decision.disposition == disposition)
945            .count()
946    };
947    // `blocked_objects`/`quarantined_objects` must equal the itemized
948    // decisions (contract); every other count includes what was not
949    // itemized, so a large request is described in full.
950    let blocked = count(ContextDispositionV1::Deny);
951    let quarantined = count(ContextDispositionV1::Quarantine);
952    let permitted = saturating_u32(
953        decisions
954            .iter()
955            .filter(|decision| decision.object != note && decision.disposition.delivers_content())
956            .count(),
957    )
958    .saturating_add(overflow.permitted);
959    let redactions: u32 = records
960        .iter()
961        .map(Recorded::redactions)
962        .fold(overflow.redactions, u32::saturating_add);
963    let injection: u32 = records
964        .iter()
965        .map(|record| record.counts.injection)
966        .fold(overflow.injection, u32::saturating_add);
967    let incomplete = saturating_u32(
968        records
969            .iter()
970            .filter(|record| record.counts.incomplete > 0)
971            .count(),
972    )
973    .saturating_add(overflow.incomplete);
974
975    // AP10-C4: a receipt is version 2 — its objects keyed under the
976    // installation's receipt key. No key, no receipt: a plain digest of a
977    // single string confirms a guess of it, so none is ever emitted again.
978    let object_key = super::receipt_store::keyed::object_key().ok()?;
979    let decisions: Vec<ContextDecisionV1> = decisions
980        .into_iter()
981        .map(|mut decision| {
982            decision.object = object_key.object(&decision.object);
983            decision
984        })
985        .collect();
986    let outcome = if permitted == 0 {
987        DeliveryOutcomeV1::Withheld
988    } else if delivered.is_error {
989        DeliveryOutcomeV1::Failed
990    } else {
991        DeliveryOutcomeV1::Delivered
992    };
993    let final_context =
994        (outcome == DeliveryOutcomeV1::Delivered).then(|| sha256(delivered.text.as_bytes()));
995    let principal = presenter_ref
996        .and_then(|presenter| ProtocolReference::new(presenter).ok())
997        .and_then(|id| PrincipalV1::new(PrincipalKindV1::Session, id).ok())
998        .or_else(|| {
999            identity
1000                .agent_id
1001                .and_then(|agent| reference("agent:", agent))
1002                .and_then(|id| PrincipalV1::new(PrincipalKindV1::Agent, id).ok())
1003        })
1004        .unwrap_or_else(PrincipalV1::unknown);
1005    let receipt_seed = format!(
1006        "{}|{}|{}|{:?}",
1007        policy_digest.as_str(),
1008        decisions
1009            .iter()
1010            .map(|decision| decision.object.as_str())
1011            .collect::<Vec<_>>()
1012            .join(","),
1013        final_context.as_ref().map_or("", Sha256Digest::as_str),
1014        capture.started
1015    );
1016    let receipt = ContextDecisionReceiptV1 {
1017        schema_version: RECEIPT_V2_SCHEMA_VERSION,
1018        object_digest: Some(object_key.descriptor()),
1019        receipt_id: reference(
1020            "rcpt-",
1021            &blake3::hash(receipt_seed.as_bytes()).to_hex()[..32],
1022        )?,
1023        mode,
1024        principal,
1025        task: capture
1026            .task
1027            .lock()
1028            .unwrap_or_else(PoisonError::into_inner)
1029            .as_ref()
1030            .and_then(|(task, _)| TaskId::new(task.as_str()).ok()),
1031        // An MCP call serves the host; where the host sends the context is
1032        // not known there, so locality is honestly unknown. The proxy names
1033        // the real model destination (G6).
1034        destination: identity
1035            .destination
1036            .clone()
1037            .unwrap_or_else(|| DestinationV1 {
1038                provider: ProtocolReference::new("mcp-host").expect("valid provider ref"),
1039                model: None,
1040                locality: DestinationLocalityV1::Unknown,
1041                organization_managed: false,
1042                account_ref: None,
1043                region: None,
1044            }),
1045        policy: Some(PolicyRefV1 {
1046            id: PolicyId::new("context-gateway").expect("valid policy id"),
1047            version: None,
1048            digest: policy_digest,
1049        }),
1050        sources: SourceCountsV1 {
1051            inspected: saturating_u32(decisions.len() - notes).saturating_add(overflow.objects),
1052            permitted,
1053            selected: permitted,
1054            blocked: saturating_u32(blocked).saturating_add(overflow.blocked),
1055        },
1056        decisions,
1057        security: SecurityCountsV1 {
1058            redactions,
1059            blocked_objects: saturating_u32(blocked),
1060            quarantined_objects: saturating_u32(quarantined),
1061            injection_signals: injection,
1062            incomplete_coverage: incomplete,
1063        },
1064        tokens: TokenAccountV1 {
1065            // The tool's own tokenizer measurement of its sources; a tool that
1066            // reports none delivered its sources as they were.
1067            original: capture
1068                .original_tokens
1069                .lock()
1070                .unwrap_or_else(PoisonError::into_inner)
1071                .filter(|tokens| *tokens > 0)
1072                .unwrap_or(delivered.tokens),
1073            delivered: delivered.tokens,
1074        },
1075        final_context,
1076        outcome,
1077        duration_us: u64::try_from(capture.started.elapsed().as_micros()).unwrap_or(u64::MAX),
1078        quality: None,
1079    };
1080    receipt.validate().ok()?;
1081    Some(receipt)
1082}