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    /// The object's class has an external or unknown origin floor: it is
39    /// never clean for displacement, so it stays itemized (pz21 §1.5 item 2).
40    external: bool,
41}
42
43/// The reason a receipt itemizes an object of external or unknown origin
44/// with (pz21 §1.5 item 2). Content-free.
45pub const UNTRUSTED_ORIGIN: &str = "origin.untrusted";
46
47/// Most distinct origins one call's ledger records; one more sets overflow.
48pub const MAX_LEDGER_REFS: usize = 256;
49
50/// The origin ledger of one call (pz21 §1.5): every admission whose class is
51/// external or unknown, independent of the capped decision records, so the
52/// `MAX_DECISIONS` cap can never displace it.
53#[derive(Debug, Clone, Default, PartialEq, Eq)]
54pub struct OriginLedger {
55    /// Every external or unknown admission, saturating.
56    pub count: u64,
57    /// The distinct origins, bounded by [`MAX_LEDGER_REFS`].
58    pub refs: std::collections::BTreeSet<super::origin::InputRef>,
59    /// A ref too long to record, or the set full.
60    pub overflow: bool,
61}
62
63impl OriginLedger {
64    fn note(&mut self, input: Option<super::origin::InputRef>) {
65        self.count = self.count.saturating_add(1);
66        match input {
67            Some(input) if self.refs.contains(&input) => {}
68            Some(input) if self.refs.len() < MAX_LEDGER_REFS => {
69                self.refs.insert(input);
70            }
71            _ => self.overflow = true,
72        }
73    }
74
75    fn merge(&mut self, other: &Self) {
76        self.count = self.count.saturating_add(other.count);
77        for input in &other.refs {
78            if !self.refs.contains(input) {
79                if self.refs.len() < MAX_LEDGER_REFS {
80                    self.refs.insert(input.clone());
81                } else {
82                    self.overflow = true;
83                }
84            }
85        }
86        self.overflow |= other.overflow;
87    }
88
89    /// Nothing external or unknown was admitted.
90    pub fn is_empty(&self) -> bool {
91        self.count == 0 && !self.overflow
92    }
93}
94
95/// Process-wide count of external or unknown admissions that found neither a
96/// capture nor an `outside_call` scope (pz21 §1.5 item 4b). Every call open
97/// while it moves is released as if its ledger overflowed.
98pub static ORPHAN_EXTERNAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
99
100thread_local! {
101    /// Depth of `outside_call` scopes on this thread.
102    static OUTSIDE_CALL: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
103    /// Depth of `external_span` scopes on this thread.
104    static IN_SPAN: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
105    /// Depth of `egress_check` scopes on this thread.
106    static EGRESS: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
107}
108
109/// Whether the calling thread runs inside [`outside_call`].
110pub fn outside_call_active() -> bool {
111    OUTSIDE_CALL.with(std::cell::Cell::get) > 0
112}
113
114/// Whether the calling thread runs an egress-equality check
115/// ([`egress_check`]): its text is compared, never delivered or stored, so
116/// its injection action is never raised (NB-2).
117pub fn egress_check_active() -> bool {
118    EGRESS.with(std::cell::Cell::get) > 0
119}
120
121/// Run `check` as an egress-equality check: outside the call
122/// ([`outside_call`]) and with the injection action left as configured.
123pub fn egress_check<T>(check: impl FnOnce() -> T) -> T {
124    struct Leave;
125    impl Drop for Leave {
126        fn drop(&mut self) {
127            EGRESS.with(|depth| depth.set(depth.get().saturating_sub(1)));
128        }
129    }
130    EGRESS.with(|depth| depth.set(depth.get().saturating_add(1)));
131    let _leave = Leave;
132    outside_call(check)
133}
134
135/// Run `operation` as an admission that is outside any tool call by design
136/// (egress checks, detached store writes, background index builds). Inside
137/// it an admission writes no ledger entry, raises no orphan mark and never
138/// taints. Its text reaches an agent only through a reader that admits it
139/// again at serve time, inside the call.
140pub fn outside_call<T>(operation: impl FnOnce() -> T) -> T {
141    struct Leave;
142    impl Drop for Leave {
143        fn drop(&mut self) {
144            OUTSIDE_CALL.with(|depth| depth.set(depth.get().saturating_sub(1)));
145        }
146    }
147    OUTSIDE_CALL.with(|depth| depth.set(depth.get().saturating_add(1)));
148    let _leave = Leave;
149    operation()
150}
151
152/// Note that an admission of `class` happened (pz21 §1.5 item 1). Called
153/// first by every entry point that takes or derives a class, ahead of the
154/// config refusal, the gateway-off return, the clean memo and the
155/// detectors. A `Project` class notes nothing.
156pub fn note_origin(class: &super::origin::SourceClass) {
157    if !class.is_external_or_unknown() || outside_call_active() {
158        return;
159    }
160    let input = class.input_ref();
161    match current() {
162        Some(capture) => capture.note_origin_ref(input),
163        None => {
164            ORPHAN_EXTERNAL.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
165        }
166    }
167}
168
169/// The capture and the `outside_call` marker of the calling thread, for a
170/// worker that admits on the call's behalf (`rayon`, `std::thread`).
171#[derive(Clone)]
172pub struct Carried {
173    capture: Option<Arc<AdmissionCapture>>,
174    outside: bool,
175    project: Option<std::path::PathBuf>,
176}
177
178/// Take what a worker needs to admit as part of this call (pz21 §1.5 4a).
179pub fn carry() -> Carried {
180    Carried {
181        capture: current(),
182        outside: outside_call_active(),
183        project: crate::core::policy::runtime::REQUEST_PROJECT
184            .try_with(|slot| slot.borrow().clone())
185            .ok()
186            .flatten(),
187    }
188}
189
190impl Carried {
191    /// Run `operation` on this worker as part of the carrying call.
192    pub fn enter<T>(&self, operation: impl FnOnce() -> T) -> T {
193        let inner = || {
194            if self.outside {
195                outside_call(operation)
196            } else {
197                operation()
198            }
199        };
200        let with_project = || match &self.project {
201            Some(project) if crate::core::policy::diagnostics::request_project().is_none() => {
202                crate::core::policy::runtime::in_project(project.clone(), inner)
203            }
204            _ => inner(),
205        };
206        if self.capture.is_some() && current().is_none() {
207            ADMISSIONS.sync_scope(self.capture.clone(), with_project)
208        } else {
209            with_project()
210        }
211    }
212}
213
214/// Admit text that a tool delivers as one marked part of a block (a hint
215/// line quoting an issue title, pz21 §1.6). Its origins go into the call's
216/// span list instead of the ledger, and the release point fences exactly
217/// that part when it finds it in a text block; otherwise it fences every
218/// block whole.
219pub fn external_span(
220    class: &super::origin::SourceClass,
221    admit: impl FnOnce() -> Option<String>,
222) -> Option<String> {
223    let Some(capture) =
224        current().filter(|_| class.is_external_or_unknown() && !outside_call_active())
225    else {
226        return admit();
227    };
228    struct Leave;
229    impl Drop for Leave {
230        fn drop(&mut self) {
231            IN_SPAN.with(|depth| depth.set(depth.get().saturating_sub(1)));
232        }
233    }
234    IN_SPAN.with(|depth| depth.set(depth.get().saturating_add(1)));
235    let admitted = {
236        let _leave = Leave;
237        admit()
238    };
239    if let Some(text) = &admitted
240        && !text.is_empty()
241    {
242        capture
243            .spans
244            .lock()
245            .unwrap_or_else(PoisonError::into_inner)
246            .push((class.input_ref(), text.clone()));
247    }
248    admitted
249}
250
251fn in_span() -> bool {
252    IN_SPAN.with(std::cell::Cell::get) > 0
253}
254
255/// The policy snapshot of ONE call (G08), taken once at its entry — where the
256/// MCP dispatcher opens the call's capture, where a CLI run starts — and
257/// carried by the capture: the gateway rules, whether protection is active
258/// for the request's project, and the active policy pack. While the capture
259/// is open, `carrier::current_policy` returns these rules, so the release
260/// point, the receipt duty, the trace sidecar and the stored-copy checks of
261/// the call decide under the rules of its entry, whatever is configured by
262/// the time they run.
263#[derive(Debug, Clone)]
264pub struct CallSnapshot {
265    admission: super::stores::StoreAdmission,
266    protected: bool,
267    pack: Option<(String, String)>,
268    /// The organization decision the verifier stated in the verification the
269    /// pack above comes from; `None` when no verifier was asked.
270    org_policy: Option<OrgObservation>,
271}
272
273/// See [`crate::core::policy::org::Observation`].
274pub(crate) type OrgObservation = crate::core::policy::org::Observation;
275
276/// The organization decision a capture's admissions were made under.
277#[derive(Debug, Clone, PartialEq, Eq, Default)]
278pub enum OrgEvidence {
279    /// No verifier decision was observed.
280    #[default]
281    None,
282    /// Every admission of the capture rests on this one observation.
283    One(OrgObservation),
284    /// Admissions that were merged into one capture rest on different
285    /// observations (or one of them on none). No single organization decision
286    /// can be named for the delivery, and none is: the record says so instead
287    /// of naming the first or the last.
288    Diverged,
289    /// The admission ran where no verified view was held to take an
290    /// observation from (an opaque body admitted outside a source view). It
291    /// did not ask a verifier of its own; the record says that no
292    /// organization decision is available for it — not that there is none.
293    Unavailable,
294}
295
296impl OrgEvidence {
297    fn of(observation: Option<OrgObservation>) -> Self {
298        observation.map_or(Self::None, Self::One)
299    }
300}
301
302impl CallSnapshot {
303    /// Take the snapshot now. Call inside the request scope: protection and
304    /// the pack are those of the request's project. Inside an open capture
305    /// that carries a snapshot (a run that is part of a call) the gateway
306    /// rules are that call's.
307    pub fn take() -> Self {
308        let admission = super::carrier::gateway_snapshot();
309        let protected =
310            super::stored::source_authority_required_under(admission.admission_policy());
311        // One read: the pack and the organization observation are those of
312        // the same verified policy.
313        let (active, org_policy) = crate::core::policy::runtime::active_observed();
314        let pack = active.map(|active| {
315            (
316                active.resolved.name.clone(),
317                active.resolved.version.clone(),
318            )
319        });
320        Self {
321            admission,
322            protected,
323            pack,
324            org_policy,
325        }
326    }
327
328    /// The gateway rules of the call.
329    pub fn admission(&self) -> &super::stores::StoreAdmission {
330        &self.admission
331    }
332
333    /// Protection was active at the call's entry (a project policy pack, the
334    /// gateway in governed or sovereign mode, an unloadable configuration).
335    pub(crate) fn protected(&self) -> bool {
336        self.protected
337    }
338
339    /// The organization decision observed with the pack at the call's entry.
340    pub(crate) fn org_policy(&self) -> OrgEvidence {
341        OrgEvidence::of(self.org_policy.clone())
342    }
343
344    /// Name and version of the policy pack active at the call's entry.
345    pub(crate) fn pack(&self) -> Option<&(String, String)> {
346        self.pack.as_ref()
347    }
348}
349
350/// Source references one capture holds for its trace sidecar.
351pub(crate) const MAX_SOURCE_REFERENCES: usize = 64;
352/// Longest source reference recorded; a longer one is counted, not recorded.
353pub(crate) const MAX_SOURCE_REFERENCE_BYTES: usize = 256;
354
355/// The name a reader showed for a source it delivered.
356#[derive(Debug, Clone, PartialEq, Eq)]
357pub enum SourceName {
358    /// A project file: its path relative to the project.
359    Path(String),
360    /// Not a project file: the id it has at its origin (the path in a
361    /// remote repository, a provider's source id).
362    Id(String),
363}
364
365/// What the admitted readers named of the sources this call admitted.
366#[derive(Debug, Default)]
367struct SourceNotes {
368    /// Object digest of an itemized decision → the name its reader
369    /// delivered it under.
370    named: Vec<(Sha256Digest, SourceName)>,
371    /// References that were not recorded (bound reached, not expressible).
372    omitted: u32,
373}
374
375thread_local! {
376    /// The delivering source decision this thread recorded last and no reader
377    /// has named yet: the capture it belongs to and the decision's object.
378    /// An admitted reader admits a source and publishes it on one thread, in
379    /// that order; [`note_source`] takes the entry, so one decision is named
380    /// at most once and a publication without a decision names nothing.
381    static UNNAMED: std::cell::RefCell<Option<(usize, Sha256Digest)>> =
382        const { std::cell::RefCell::new(None) };
383}
384
385/// Name the source the calling thread admitted last (G08): the reference the
386/// reader shows for it — its path relative to `project`. Called by the
387/// admitted readers' publication (`core::archive::authority::publish_read`),
388/// i.e. only for a source that was delivered: a withheld source is never
389/// named here, and the sidecar names it as withheld, as the reader does. A
390/// path outside the project, not UTF-8 or longer than the bound is counted,
391/// not recorded. No capture: nothing happens.
392pub(crate) fn note_source(project: &std::path::Path, canonical_path: &str) {
393    let Some((capture, object)) = unnamed_decision() else {
394        return;
395    };
396    let path = std::path::Path::new(canonical_path);
397    let relative = path
398        .strip_prefix(project)
399        .ok()
400        .map(std::path::Path::to_path_buf)
401        .or_else(|| {
402            let project = crate::core::pathutil::canonicalize_secure(project).ok()?;
403            path.strip_prefix(&project)
404                .ok()
405                .map(std::path::Path::to_path_buf)
406        });
407    let reference = relative
408        .as_deref()
409        .and_then(std::path::Path::to_str)
410        .filter(|reference| !reference.is_empty() && reference.len() <= MAX_SOURCE_REFERENCE_BYTES)
411        .map(|reference| SourceName::Path(reference.replace('\\', "/")));
412    capture.name_source(object, reference);
413}
414
415/// `note_source` for a source that is not a project file (G08): `id` is
416/// what its reader shows for it — the path it has at its origin, a
417/// provider's source id. Called by the reader right after it admitted the
418/// source, on the same thread, and only for a source it delivers. The same
419/// bounds apply; an empty or over-long id is counted, not recorded. The
420/// sidecar keeps it only if it passes the release check as a word.
421pub fn note_source_id(id: &str) {
422    let Some((capture, object)) = unnamed_decision() else {
423        return;
424    };
425    let reference = (!id.is_empty() && id.len() <= MAX_SOURCE_REFERENCE_BYTES)
426        .then(|| SourceName::Id(id.to_owned()));
427    capture.name_source(object, reference);
428}
429
430/// The open capture and the object of the delivering decision the calling
431/// thread recorded into it last and that no reader has named yet. Taking it
432/// spends it.
433fn unnamed_decision() -> Option<(Arc<AdmissionCapture>, Sha256Digest)> {
434    let capture = current()?;
435    let owner = std::ptr::from_ref::<AdmissionCapture>(&capture).addr();
436    let (recorded_by, object) = UNNAMED.with(|slot| slot.borrow_mut().take())?;
437    (recorded_by == owner).then_some((capture, object))
438}
439
440/// Everything one call contributes to its receipt.
441#[derive(Debug)]
442pub struct AdmissionCapture {
443    started: Instant,
444    records: Mutex<Vec<Recorded>>,
445    /// Decisions beyond `MAX_DECISIONS` are counted in full, never dropped.
446    overflow: Mutex<Overflow>,
447    /// Original tokens the tool measured for its sources (pre-optimization).
448    original_tokens: Mutex<Option<u64>>,
449    policy: Mutex<Option<(GatewayModeV1, Sha256Digest)>>,
450    /// The task the handler ran under and its tenant/project scope, bound
451    /// where admission happens.
452    task: Mutex<Option<(String, String)>>,
453    /// The call's policy snapshot, when the capture was opened for a call
454    /// ([`Self::for_call`]). A proxy egress capture has none: its admission
455    /// holds its own snapshot.
456    snapshot: Option<CallSnapshot>,
457    /// An admission of this call was decided under other gateway rules than
458    /// the call's snapshot.
459    diverged: std::sync::atomic::AtomicBool,
460    sources: Mutex<SourceNotes>,
461    /// Name and version of the policy pack an egress admission resolved.
462    pack: Mutex<Option<(String, String)>>,
463    /// The organization decision an egress admission's own snapshot observed
464    /// (`None`: nothing noted yet).
465    org_policy: Mutex<Option<OrgEvidence>>,
466    /// The origin ledger: never displaced by the record cap (pz21 §1.5).
467    origin: Mutex<OriginLedger>,
468    /// Marked parts of a block: external text a tool placed itself.
469    spans: Mutex<Vec<(Option<super::origin::InputRef>, String)>>,
470    /// [`ORPHAN_EXTERNAL`] when this capture was opened.
471    orphan_mark: u64,
472    /// The class of this call's command output, when the shell tool
473    /// classified it as `tool_output` (pz21 §1.8).
474    output_class: Mutex<Option<super::origin::SourceClass>>,
475    /// Digests of the text blocks the release point's origin step delivered
476    /// for this call: a block counts as already fenced only when it is one
477    /// of these, never because its text looks like a fence (outside text can
478    /// spell a fence header and closing line itself).
479    released: Mutex<std::collections::BTreeSet<[u8; 32]>>,
480}
481
482/// Most released blocks one capture remembers; past it a block released
483/// again is fenced again (fails closed: a double fence, never none).
484const MAX_RELEASED_BLOCKS: usize = 4096;
485
486impl Default for AdmissionCapture {
487    fn default() -> Self {
488        Self {
489            released: Mutex::new(std::collections::BTreeSet::new()),
490            origin: Mutex::new(OriginLedger::default()),
491            spans: Mutex::new(Vec::new()),
492            orphan_mark: ORPHAN_EXTERNAL.load(std::sync::atomic::Ordering::SeqCst),
493            output_class: Mutex::new(None),
494            started: Instant::now(),
495            records: Mutex::new(Vec::new()),
496            overflow: Mutex::new(Overflow::default()),
497            original_tokens: Mutex::new(None),
498            policy: Mutex::new(None),
499            task: Mutex::new(None),
500            snapshot: None,
501            diverged: std::sync::atomic::AtomicBool::new(false),
502            sources: Mutex::new(SourceNotes::default()),
503            pack: Mutex::new(None),
504            org_policy: Mutex::new(None),
505        }
506    }
507}
508
509impl AdmissionCapture {
510    pub fn new() -> Arc<Self> {
511        Arc::new(Self::default())
512    }
513
514    /// The capture of one call, carrying the policy snapshot taken at the
515    /// call's entry.
516    pub fn for_call(snapshot: CallSnapshot) -> Arc<Self> {
517        Arc::new(Self {
518            snapshot: Some(snapshot),
519            ..Self::default()
520        })
521    }
522
523    /// The call's policy snapshot, when this capture was opened for a call.
524    pub fn snapshot(&self) -> Option<&CallSnapshot> {
525        self.snapshot.as_ref()
526    }
527
528    /// The shell tool classified this call's command output (pz21 §1.8):
529    /// every command output it admits takes this class.
530    pub fn set_output_class(&self, class: super::origin::SourceClass) {
531        *self
532            .output_class
533            .lock()
534            .unwrap_or_else(PoisonError::into_inner) = Some(class);
535    }
536
537    /// The class of this call's command output; `None`: the project's own.
538    pub fn output_class(&self) -> Option<super::origin::SourceClass> {
539        self.output_class
540            .lock()
541            .unwrap_or_else(PoisonError::into_inner)
542            .clone()
543    }
544
545    fn note_origin_ref(&self, input: Option<super::origin::InputRef>) {
546        if in_span() {
547            return;
548        }
549        self.origin
550            .lock()
551            .unwrap_or_else(PoisonError::into_inner)
552            .note(input);
553    }
554
555    /// The call's origin ledger. An orphan mark raised since the capture
556    /// opened reads as overflow (pz21 §1.5 item 4b).
557    pub fn origin_ledger(&self) -> OriginLedger {
558        let mut ledger = self
559            .origin
560            .lock()
561            .unwrap_or_else(PoisonError::into_inner)
562            .clone();
563        if self.orphan_external() {
564            ledger.overflow = true;
565        }
566        ledger
567    }
568
569    /// An external or unknown admission without capture or `outside_call`
570    /// happened somewhere in the process while this call was open.
571    pub fn orphan_external(&self) -> bool {
572        ORPHAN_EXTERNAL.load(std::sync::atomic::Ordering::SeqCst) != self.orphan_mark
573    }
574
575    /// The marked external parts tools placed in their blocks.
576    pub fn external_spans(&self) -> Vec<(Option<super::origin::InputRef>, String)> {
577        self.spans
578            .lock()
579            .unwrap_or_else(PoisonError::into_inner)
580            .clone()
581    }
582
583    /// Record that the origin step delivered `text` as a block of this call.
584    pub fn mark_released(&self, text: &str) {
585        let mut released = self.released.lock().unwrap_or_else(PoisonError::into_inner);
586        if released.len() < MAX_RELEASED_BLOCKS {
587            released.insert(*blake3::hash(text.as_bytes()).as_bytes());
588        }
589    }
590
591    /// Whether the origin step already delivered exactly `text` for this
592    /// call (a result released twice).
593    pub fn was_released(&self, text: &str) -> bool {
594        self.released
595            .lock()
596            .unwrap_or_else(PoisonError::into_inner)
597            .contains(blake3::hash(text.as_bytes()).as_bytes())
598    }
599
600    /// Every outside origin this call's output carries so far: the ledger
601    /// with the marked parts folded in. What a reused output (idempotent
602    /// replay) must bring into the call that serves it again.
603    pub fn output_origins(&self) -> OriginLedger {
604        let mut ledger = self.origin_ledger();
605        for (input, _) in self.external_spans() {
606            ledger.note(input);
607        }
608        ledger
609    }
610
611    /// Note the origins of an output produced by an earlier call and served
612    /// again by this one (idempotent replay): its release fences and taints
613    /// as the first release did.
614    pub fn renote_origins(&self, origins: &OriginLedger) {
615        self.origin
616            .lock()
617            .unwrap_or_else(PoisonError::into_inner)
618            .merge(origins);
619    }
620
621    /// Fold the marked parts into the ledger: the tool's placement did not
622    /// survive to the release point, so every block is fenced whole.
623    pub fn spans_into_ledger(&self) {
624        let spans = std::mem::take(&mut *self.spans.lock().unwrap_or_else(PoisonError::into_inner));
625        let mut ledger = self.origin.lock().unwrap_or_else(PoisonError::into_inner);
626        for (input, _) in spans {
627            ledger.note(input);
628        }
629    }
630
631    /// Whether an admission recorded here was decided under other gateway
632    /// rules than the call's snapshot. Always `false` without a snapshot.
633    pub(crate) fn policy_diverged(&self) -> bool {
634        self.diverged.load(std::sync::atomic::Ordering::SeqCst)
635    }
636
637    /// The references the admitted readers named, by the object digest of
638    /// the decision each belongs to, and how many were not recorded.
639    pub fn source_references(&self) -> (Vec<(Sha256Digest, SourceName)>, u32) {
640        let sources = self.sources.lock().unwrap_or_else(PoisonError::into_inner);
641        (sources.named.clone(), sources.omitted)
642    }
643
644    /// Record the name of the decision about `object`, or count it as not
645    /// recorded (`None`, or the bound is reached).
646    fn name_source(&self, object: Sha256Digest, name: Option<SourceName>) {
647        let mut sources = self.sources.lock().unwrap_or_else(PoisonError::into_inner);
648        match name {
649            Some(name) if sources.named.len() < MAX_SOURCE_REFERENCES => {
650                sources.named.push((object, name));
651            }
652            _ => sources.omitted = sources.omitted.saturating_add(1),
653        }
654    }
655
656    /// The name of the policy pack the egress admission of this capture
657    /// resolved (proxy), for its trace sidecar.
658    pub(super) fn note_pack(&self, name: &str, version: &str) {
659        *self.pack.lock().unwrap_or_else(PoisonError::into_inner) =
660            Some((name.to_owned(), version.to_owned()));
661    }
662
663    /// The organization decision the egress admission of this capture
664    /// observed in its own snapshot — noted once per admission, also when it
665    /// observed none. A second note that differs makes the evidence
666    /// [`OrgEvidence::Diverged`]; it never replaces the first.
667    pub(super) fn note_org_policy(&self, observation: Option<OrgObservation>) {
668        self.merge_org_policy(OrgEvidence::of(observation));
669    }
670
671    /// The egress admission of this capture held no verified view to take an
672    /// observation from, and asked for none.
673    pub(super) fn note_org_policy_unavailable(&self) {
674        self.merge_org_policy(OrgEvidence::Unavailable);
675    }
676
677    fn merge_org_policy(&self, incoming: OrgEvidence) {
678        let mut noted = self
679            .org_policy
680            .lock()
681            .unwrap_or_else(PoisonError::into_inner);
682        *noted = Some(match noted.take() {
683            None => incoming,
684            Some(present) if present == incoming => present,
685            Some(_) => OrgEvidence::Diverged,
686        });
687    }
688
689    /// The organization decision of the capture: the call snapshot's, or
690    /// what its egress admissions noted.
691    pub(crate) fn org_policy(&self) -> OrgEvidence {
692        match &self.snapshot {
693            Some(snapshot) => OrgEvidence::of(snapshot.org_policy.clone()),
694            None => self
695                .org_policy
696                .lock()
697                .unwrap_or_else(PoisonError::into_inner)
698                .clone()
699                .unwrap_or_default(),
700        }
701    }
702
703    /// The pack of the capture: the call snapshot's, or the one an egress
704    /// admission noted.
705    pub(crate) fn pack(&self) -> Option<(String, String)> {
706        self.snapshot
707            .as_ref()
708            .and_then(|snapshot| snapshot.pack.clone())
709            .or_else(|| {
710                self.pack
711                    .lock()
712                    .unwrap_or_else(PoisonError::into_inner)
713                    .clone()
714            })
715    }
716
717    pub(super) fn record(&self, decision: &ContextDecisionV1, counts: AdmissionCounts) {
718        self.record_as(decision, counts, false);
719    }
720
721    /// [`Self::record`] for an object whose class is `external` or unknown:
722    /// such a decision is never clean for displacement (pz21 §1.5 item 2).
723    pub(super) fn record_as(
724        &self,
725        decision: &ContextDecisionV1,
726        counts: AdmissionCounts,
727        external: bool,
728    ) {
729        if let Some(envelope) = crate::core::task_spine::TaskSpine::current() {
730            let mut task = self.task.lock().unwrap_or_else(PoisonError::into_inner);
731            if task.is_none() {
732                *task = Some((
733                    envelope.task_id.as_str().to_owned(),
734                    crate::core::context_store::task_scope(
735                        envelope.tenant_id.as_ref(),
736                        &envelope.project_id,
737                    ),
738                ));
739            }
740        }
741        // The reader that admitted this source may name it next, on this
742        // thread ([`note_source`]); a note or a withheld source is not named.
743        let nameable = decision.object != note_object() && decision.disposition.delivers_content();
744        UNNAMED.with(|slot| {
745            *slot.borrow_mut() =
746                nameable.then(|| (std::ptr::from_ref(self).addr(), decision.object.clone()));
747        });
748        let mut decision = decision.clone();
749        // The receipt itemizes an external or unknown object as not clean,
750        // with a content-free reason (also on a clean-memo hit).
751        if external {
752            let code =
753                ReasonCodeV1::new(UNTRUSTED_ORIGIN).expect("built-in reason codes are valid");
754            if !decision.reason_codes.contains(&code) {
755                decision.reason_codes.push(code);
756            }
757        }
758        let incoming = Recorded {
759            decision,
760            counts,
761            external,
762        };
763        let mut records = self.records.lock().unwrap_or_else(PoisonError::into_inner);
764        if records.len() < MAX_DECISIONS {
765            records.push(incoming);
766            return;
767        }
768        // The receipt holds at most MAX_DECISIONS decisions. A notable one
769        // (changed, withheld, flagged) displaces a clean one, so every
770        // decision the receipt is about stays itemized; whatever is not
771        // itemized still counts in full.
772        let displaced = if incoming.is_clean() {
773            incoming
774        } else if let Some(position) = records.iter().position(Recorded::is_clean) {
775            std::mem::replace(&mut records[position], incoming)
776        } else if incoming.is_note() {
777            // A note adds to no count, so one that finds no slot would be
778            // gone without a trace. The receipt says so instead: one fixed
779            // note stands for every note it does not itemize.
780            let Some(displaced) = mark_notes_not_itemized(&mut records) else {
781                return;
782            };
783            displaced
784        } else {
785            incoming
786        };
787        self.overflow
788            .lock()
789            .unwrap_or_else(PoisonError::into_inner)
790            .add(&displaced);
791    }
792
793    pub(super) fn bind_policy(&self, mode: GatewayModeV1, digest: Sha256Digest) {
794        if let Some(snapshot) = &self.snapshot
795            && snapshot.admission.digest() != digest.hex()
796        {
797            self.diverged
798                .store(true, std::sync::atomic::Ordering::SeqCst);
799        }
800        *self.policy.lock().unwrap_or_else(PoisonError::into_inner) = Some((mode, digest));
801    }
802
803    /// The tenant/project scope of the call's task, for its receipt index.
804    pub(crate) fn task_scope(&self) -> Option<String> {
805        self.task
806            .lock()
807            .unwrap_or_else(PoisonError::into_inner)
808            .as_ref()
809            .map(|(_, scope)| scope.clone())
810    }
811
812    /// The measured original token count reported by the tool for this call.
813    pub fn set_original_tokens(&self, tokens: u64) {
814        *self
815            .original_tokens
816            .lock()
817            .unwrap_or_else(PoisonError::into_inner) = Some(tokens);
818    }
819
820    pub fn is_empty(&self) -> bool {
821        self.records
822            .lock()
823            .unwrap_or_else(PoisonError::into_inner)
824            .is_empty()
825    }
826
827    /// Whether the capture holds a decision about a source, itemized or only
828    /// counted: then the delivery owes a receipt. Notes alone describe no
829    /// source — the rule by which [`finish_for_presenter`] returns `None`.
830    pub(crate) fn describes_sources(&self) -> bool {
831        let note = note_object();
832        let itemized = self
833            .records
834            .lock()
835            .unwrap_or_else(PoisonError::into_inner)
836            .iter()
837            .any(|record| record.decision.object != note);
838        itemized
839            || self
840                .overflow
841                .lock()
842                .unwrap_or_else(PoisonError::into_inner)
843                .objects
844                > 0
845    }
846
847    /// Make a delivery that carries bytes but no admitted source decision a
848    /// receipted one (G08): the delivered output itself becomes the one
849    /// itemized object, permitted, with the fixed reason
850    /// [`NO_SOURCE_DECISION`]. The receipt then says exactly that — bytes
851    /// left without a source decision — instead of there being no receipt.
852    /// A capture no admission bound is bound to `mode` and `policy`.
853    pub(super) fn record_unsourced(
854        &self,
855        output: Sha256Digest,
856        mode: GatewayModeV1,
857        policy: Sha256Digest,
858    ) {
859        if self.bound_policy().is_none() {
860            self.bind_policy(mode, policy);
861        }
862        let decision = ContextDecisionV1 {
863            object: output,
864            disposition: ContextDispositionV1::Allow,
865            reason_codes: vec![
866                ReasonCodeV1::new(NO_SOURCE_DECISION).expect("built-in reason codes are valid"),
867            ],
868            signals: Vec::new(),
869            required_transformations: Vec::new(),
870        };
871        self.record(&decision, AdmissionCounts::default());
872    }
873
874    /// The itemized decisions with the counts admission measured for each,
875    /// for the trace sidecar: what a V1 receipt holds only as totals.
876    pub fn itemized(&self) -> Vec<(ContextDecisionV1, AdmissionCounts)> {
877        self.records
878            .lock()
879            .unwrap_or_else(PoisonError::into_inner)
880            .iter()
881            .map(|record| (record.decision.clone(), record.counts))
882            .collect()
883    }
884
885    /// Sources the receipt counts without itemizing them.
886    pub(crate) fn counted_only(&self) -> u32 {
887        self.overflow
888            .lock()
889            .unwrap_or_else(PoisonError::into_inner)
890            .objects
891    }
892
893    /// The mode and policy digest this capture is bound to, if any.
894    pub(super) fn bound_policy(&self) -> Option<(GatewayModeV1, Sha256Digest)> {
895        self.policy
896            .lock()
897            .unwrap_or_else(PoisonError::into_inner)
898            .clone()
899    }
900
901    /// Add every decision of `earlier` to this capture, as if it had been
902    /// recorded here: itemized ones through [`Self::record`] (so the
903    /// `MAX_DECISIONS` rule holds for the union), counted-only ones into the
904    /// overflow totals. Policy binding and task of `self` are kept.
905    pub(super) fn absorb(&self, earlier: &Self) {
906        if let Some((name, version)) = earlier.pack()
907            && self.pack().is_none()
908        {
909            self.note_pack(&name, &version);
910        }
911        // Unlike the pack word, the organization decision is evidence: what
912        // an earlier admission noted is merged, and a difference is recorded
913        // as one. A capture that noted nothing contributes nothing.
914        let theirs = earlier
915            .org_policy
916            .lock()
917            .unwrap_or_else(PoisonError::into_inner)
918            .clone();
919        if let Some(theirs) = theirs {
920            self.merge_org_policy(theirs);
921        }
922        let records = earlier
923            .records
924            .lock()
925            .unwrap_or_else(PoisonError::into_inner)
926            .clone();
927        for record in &records {
928            self.record_as(&record.decision, record.counts, record.external);
929        }
930        let ledger = earlier
931            .origin
932            .lock()
933            .unwrap_or_else(PoisonError::into_inner)
934            .clone();
935        self.origin
936            .lock()
937            .unwrap_or_else(PoisonError::into_inner)
938            .merge(&ledger);
939        let spans = earlier.external_spans();
940        self.spans
941            .lock()
942            .unwrap_or_else(PoisonError::into_inner)
943            .extend(spans);
944        let released = earlier
945            .released
946            .lock()
947            .unwrap_or_else(PoisonError::into_inner)
948            .clone();
949        {
950            let mut ours = self.released.lock().unwrap_or_else(PoisonError::into_inner);
951            for digest in released {
952                if ours.len() >= MAX_RELEASED_BLOCKS {
953                    break;
954                }
955                ours.insert(digest);
956            }
957        }
958        let theirs = earlier
959            .overflow
960            .lock()
961            .unwrap_or_else(PoisonError::into_inner);
962        let mut ours = self.overflow.lock().unwrap_or_else(PoisonError::into_inner);
963        ours.objects = ours.objects.saturating_add(theirs.objects);
964        ours.permitted = ours.permitted.saturating_add(theirs.permitted);
965        ours.blocked = ours.blocked.saturating_add(theirs.blocked);
966        ours.redactions = ours.redactions.saturating_add(theirs.redactions);
967        ours.injection = ours.injection.saturating_add(theirs.injection);
968        ours.incomplete = ours.incomplete.saturating_add(theirs.incomplete);
969        ours.security.merge(&theirs.security);
970        ours.injection_flags
971            .extend(theirs.injection_flags.iter().cloned());
972    }
973
974    /// Reason codes of every itemized deny decision, in order, without
975    /// duplicates: what a refusal is made of.
976    pub(super) fn deny_reason_codes(
977        &self,
978    ) -> Vec<lean_ctx_protocol::context_gateway::ReasonCodeV1> {
979        let mut codes = Vec::new();
980        for record in self
981            .records
982            .lock()
983            .unwrap_or_else(PoisonError::into_inner)
984            .iter()
985            .filter(|record| record.decision.disposition == ContextDispositionV1::Deny)
986        {
987            for code in &record.decision.reason_codes {
988                if !codes.contains(code) {
989                    codes.push(code.clone());
990                }
991            }
992        }
993        codes
994    }
995
996    /// Whether an admitted source carried prompt-injection signals; the
997    /// dispatcher's own output scan must then not count the same text again.
998    pub fn flagged_injection(&self) -> bool {
999        self.records
1000            .lock()
1001            .unwrap_or_else(PoisonError::into_inner)
1002            .iter()
1003            .any(|record| record.counts.injection > 0)
1004    }
1005
1006    /// The call's security actions, in the event model the status line and
1007    /// `lean-ctx value` prove from the audit trail. Only what actually
1008    /// happened counts: a detected-but-delivered secret is not "kept out".
1009    pub(crate) fn security_tally(&self) -> crate::core::security_events::SecurityCounts {
1010        let mut tally = self
1011            .overflow
1012            .lock()
1013            .unwrap_or_else(PoisonError::into_inner)
1014            .security;
1015        for record in self
1016            .records
1017            .lock()
1018            .unwrap_or_else(PoisonError::into_inner)
1019            .iter()
1020        {
1021            tally.merge(&record.security());
1022        }
1023        tally
1024    }
1025
1026    /// Every object this call flagged for prompt injection, itemized or not.
1027    fn injection_flags(&self) -> Vec<crate::core::security_events::InjectionFlag> {
1028        let mut flags = self
1029            .overflow
1030            .lock()
1031            .unwrap_or_else(PoisonError::into_inner)
1032            .injection_flags
1033            .clone();
1034        flags.extend(
1035            self.records
1036                .lock()
1037                .unwrap_or_else(PoisonError::into_inner)
1038                .iter()
1039                .filter_map(Recorded::injection_flag),
1040        );
1041        flags
1042    }
1043
1044    /// Records the call's security actions once: the audit trail and the
1045    /// session counters. A flagged object counts only the first time this
1046    /// process delivers it (the receipt still carries every delivery's
1047    /// signal). Returns what was recorded.
1048    pub(crate) fn record_security(
1049        &self,
1050        tool: &str,
1051        agent_id: &str,
1052    ) -> crate::core::security_events::SecurityCounts {
1053        use crate::core::security_events::{self, SecurityKind};
1054        let mut tally = self.security_tally();
1055        tally.injection_flagged = 0;
1056        security_events::record(tool, agent_id, &tally);
1057        let flagged = security_events::record_injections(tool, agent_id, &self.injection_flags());
1058        tally.add(SecurityKind::InjectionFlagged, flagged);
1059        tally
1060    }
1061}
1062
1063/// The code of the one note that stands for every note a full receipt could
1064/// not itemize. Fixed and content-free; it carries no number — how many
1065/// blocks stayed whole is then stated by the response header and the log
1066/// line of the request, not by the receipt.
1067pub(super) const NOTES_NOT_ITEMIZED: &str = "recovery.notes_not_itemized";
1068
1069/// The reason of the one decision a receipt holds for a delivery whose bytes
1070/// no admission decided on (an empty or source-less capture under
1071/// protection, e.g. output with unknown lineage). Fixed and content-free.
1072pub(crate) const NO_SOURCE_DECISION: &str = "delivery.no_source_decision";
1073
1074/// A note found every slot of a full receipt taken by decisions the receipt
1075/// is about. Make the receipt say that notes are missing: unless it already
1076/// does, the fixed [`NOTES_NOT_ITEMIZED`] note takes the slot of an itemized
1077/// note — which it then stands for as well — or, where no note is itemized,
1078/// the slot of a source decision (one that delivered content, if there is
1079/// one). Returns that source decision: the caller counts it in full, as it
1080/// counts every decision that is not itemized. `None`: no count moves.
1081fn mark_notes_not_itemized(records: &mut [Recorded]) -> Option<Recorded> {
1082    if records.iter().any(Recorded::states_notes_not_itemized) {
1083        return None;
1084    }
1085    let marker = Recorded {
1086        decision: ContextDecisionV1 {
1087            object: note_object(),
1088            disposition: ContextDispositionV1::Allow,
1089            reason_codes: vec![
1090                ReasonCodeV1::new(NOTES_NOT_ITEMIZED).expect("built-in reason codes are valid"),
1091            ],
1092            signals: Vec::new(),
1093            required_transformations: Vec::new(),
1094        },
1095        counts: AdmissionCounts::default(),
1096        external: false,
1097    };
1098    let slot = records
1099        .iter()
1100        .rposition(Recorded::is_note)
1101        .or_else(|| {
1102            records
1103                .iter()
1104                .rposition(|record| record.decision.disposition.delivers_content())
1105        })
1106        .unwrap_or(records.len().saturating_sub(1));
1107    let displaced = std::mem::replace(records.get_mut(slot)?, marker);
1108    (!displaced.is_note()).then_some(displaced)
1109}
1110
1111impl Recorded {
1112    fn injection_flag(&self) -> Option<crate::core::security_events::InjectionFlag> {
1113        (self.counts.injection > 0).then(|| crate::core::security_events::InjectionFlag {
1114            object_sha256: self.decision.object.hex().to_owned(),
1115            patterns: self.counts.injection_patterns,
1116        })
1117    }
1118
1119    /// Delivered unchanged, nothing flagged, fully inspected.
1120    fn is_clean(&self) -> bool {
1121        self.decision.disposition == ContextDispositionV1::Allow
1122            && self.decision.reason_codes.is_empty()
1123            && !self.external
1124    }
1125
1126    /// A statement about a source, not a source ([`note_object`]).
1127    fn is_note(&self) -> bool {
1128        self.decision.object == note_object()
1129    }
1130
1131    fn states_notes_not_itemized(&self) -> bool {
1132        self.is_note()
1133            && self
1134                .decision
1135                .reason_codes
1136                .iter()
1137                .any(|reason| reason.as_str() == NOTES_NOT_ITEMIZED)
1138    }
1139
1140    /// This decision's security actions. Only what actually happened counts:
1141    /// a detected-but-delivered secret is not "kept out".
1142    fn security(&self) -> crate::core::security_events::SecurityCounts {
1143        use crate::core::security_events::{SecurityCounts, SecurityKind};
1144        let mut tally = SecurityCounts::default();
1145        let has = |code: &str| {
1146            self.decision
1147                .reason_codes
1148                .iter()
1149                .any(|reason| reason.as_str() == code)
1150        };
1151        if has("secret.redacted") {
1152            tally.add(SecurityKind::SecretRedacted, u64::from(self.counts.secrets));
1153        }
1154        if has("pii.redacted") {
1155            tally.add(SecurityKind::PiiRedacted, u64::from(self.counts.pii));
1156        }
1157        if self.counts.injection > 0 {
1158            tally.add(
1159                SecurityKind::InjectionFlagged,
1160                u64::from(self.counts.injection),
1161            );
1162        }
1163        if self.decision.disposition == ContextDispositionV1::Deny {
1164            tally.add(SecurityKind::ContentWithheld, 1);
1165        } else if self.counts.incomplete > 0 {
1166            tally.add(SecurityKind::CoverageIncomplete, 1);
1167        }
1168        tally
1169    }
1170
1171    fn redactions(&self) -> u32 {
1172        if self.decision.disposition == ContextDispositionV1::AllowRedacted {
1173            self.counts.secrets.saturating_add(self.counts.pii)
1174        } else {
1175            0
1176        }
1177    }
1178}
1179
1180/// Everything about the decisions a receipt counts but does not itemize.
1181#[derive(Debug, Default)]
1182struct Overflow {
1183    objects: u32,
1184    permitted: u32,
1185    blocked: u32,
1186    redactions: u32,
1187    injection: u32,
1188    incomplete: u32,
1189    security: crate::core::security_events::SecurityCounts,
1190    injection_flags: Vec<crate::core::security_events::InjectionFlag>,
1191}
1192
1193impl Overflow {
1194    fn add(&mut self, record: &Recorded) {
1195        // A note is a statement about a source, not a source: where it cannot
1196        // be itemized it adds to no source count.
1197        if record.decision.object == note_object() {
1198            return;
1199        }
1200        let disposition = record.decision.disposition;
1201        self.objects = self.objects.saturating_add(1);
1202        if disposition.delivers_content() {
1203            self.permitted = self.permitted.saturating_add(1);
1204        }
1205        if disposition == ContextDispositionV1::Deny {
1206            self.blocked = self.blocked.saturating_add(1);
1207        }
1208        self.redactions = self.redactions.saturating_add(record.redactions());
1209        self.injection = self.injection.saturating_add(record.counts.injection);
1210        if record.counts.incomplete > 0 {
1211            self.incomplete = self.incomplete.saturating_add(1);
1212        }
1213        self.security.merge(&record.security());
1214        self.injection_flags.extend(record.injection_flag());
1215    }
1216}
1217
1218/// Identity of the call the receipt describes.
1219pub struct CallIdentity<'a> {
1220    pub agent_id: Option<&'a str>,
1221    /// Where the context goes, when the caller knows it (the proxy does).
1222    pub destination: Option<DestinationV1>,
1223}
1224
1225/// The delivered result as the host receives it.
1226pub struct Delivered<'a> {
1227    pub text: &'a str,
1228    pub is_error: bool,
1229    pub tokens: u64,
1230}
1231
1232fn reference(prefix: &str, raw: &str) -> Option<ProtocolReference> {
1233    // Opaque references must stay bounded and printable; anything else is
1234    // reduced to a digest rather than guessed at.
1235    ProtocolReference::new(format!("{prefix}{raw}"))
1236        .ok()
1237        .or_else(|| {
1238            let digest = blake3::hash(raw.as_bytes()).to_hex();
1239            ProtocolReference::new(format!("{prefix}{}", &digest[..32])).ok()
1240        })
1241}
1242
1243fn sha256(bytes: &[u8]) -> Sha256Digest {
1244    use sha2::{Digest, Sha256};
1245    let hex = crate::core::agent_identity::hex_encode(&Sha256::digest(bytes));
1246    Sha256Digest::new(format!("sha256:{hex}")).expect("SHA-256 digest is canonical")
1247}
1248
1249fn saturating_u32(n: usize) -> u32 {
1250    u32::try_from(n).unwrap_or(u32::MAX)
1251}
1252
1253/// The fixed object of a note decision: an itemized, content-free statement
1254/// about a source the same receipt already counts (the proxy's "this block
1255/// stayed whole, and why"). A constant, so a note names no content, and the
1256/// one mark by which [`finish`] keeps notes out of the source counts — the
1257/// block a note is about was inspected and permitted as its own decision.
1258pub(super) fn note_object() -> Sha256Digest {
1259    sha256(b"proxy-block-kept-whole")
1260}
1261
1262/// Whether `decision` is a note — a statement about a source the receipt
1263/// already counts — and not a source of its own. For readers of receipts.
1264pub fn is_note(decision: &ContextDecisionV1) -> bool {
1265    let note = note_object();
1266    // A version 2 receipt names the note keyed under this installation's key.
1267    decision.object == note
1268        || super::receipt_store::keyed::object_key()
1269            .is_ok_and(|key| decision.object == key.object(&note))
1270}
1271
1272/// Build the call's receipt, or `None` when no governed source was admitted.
1273/// The receipt is validated before it is returned; an inconsistent receipt is
1274/// never produced. Product callers use `finish_for_presenter`; this form
1275/// without a presenter is what the tests call.
1276#[cfg(any(test, feature = "test-support"))]
1277pub fn finish(
1278    capture: &AdmissionCapture,
1279    identity: &CallIdentity<'_>,
1280    delivered: &Delivered<'_>,
1281) -> Option<ContextDecisionReceiptV1> {
1282    finish_for_presenter(capture, identity, None, delivered)
1283}
1284
1285/// [`finish`] for a call that showed a live presenter capability
1286/// (`core::archive::presented`, "Presenters"). `presenter_ref` is the
1287/// content-free reference of its presenter entry — a digest of a stored
1288/// verifier, never of the capability — or `None`.
1289///
1290/// With a reference the receipt's principal is of kind `Session` with that
1291/// reference as its id: it says "the holder of this session capability", so
1292/// that receipts of one presenter entry can be tied together. It is never
1293/// kind `Agent`: a capability names no agent and attests no identity. A
1294/// host-named `agent_id` is then not recorded as the principal.
1295pub(crate) fn finish_for_presenter(
1296    capture: &AdmissionCapture,
1297    identity: &CallIdentity<'_>,
1298    presenter_ref: Option<&str>,
1299    delivered: &Delivered<'_>,
1300) -> Option<ContextDecisionReceiptV1> {
1301    let records = capture
1302        .records
1303        .lock()
1304        .unwrap_or_else(PoisonError::into_inner)
1305        .clone();
1306    if records.is_empty() {
1307        return None;
1308    }
1309    let overflow = capture
1310        .overflow
1311        .lock()
1312        .unwrap_or_else(PoisonError::into_inner);
1313    let (mode, policy_digest) = capture
1314        .policy
1315        .lock()
1316        .unwrap_or_else(PoisonError::into_inner)
1317        .clone()?;
1318
1319    let decisions: Vec<ContextDecisionV1> = records
1320        .iter()
1321        .map(|record| record.decision.clone())
1322        .collect();
1323    // Notes are itemized but are no sources: they count neither as inspected
1324    // nor as permitted or selected. A capture that holds nothing but notes
1325    // describes no admitted source, exactly like an empty one.
1326    let note = note_object();
1327    let notes = decisions
1328        .iter()
1329        .filter(|decision| decision.object == note)
1330        .count();
1331    if notes == decisions.len() && overflow.objects == 0 {
1332        return None;
1333    }
1334    let count = |disposition: ContextDispositionV1| {
1335        decisions
1336            .iter()
1337            .filter(|decision| decision.disposition == disposition)
1338            .count()
1339    };
1340    // `blocked_objects`/`quarantined_objects` must equal the itemized
1341    // decisions (contract); every other count includes what was not
1342    // itemized, so a large request is described in full.
1343    let blocked = count(ContextDispositionV1::Deny);
1344    let quarantined = count(ContextDispositionV1::Quarantine);
1345    let permitted = saturating_u32(
1346        decisions
1347            .iter()
1348            .filter(|decision| decision.object != note && decision.disposition.delivers_content())
1349            .count(),
1350    )
1351    .saturating_add(overflow.permitted);
1352    let redactions: u32 = records
1353        .iter()
1354        .map(Recorded::redactions)
1355        .fold(overflow.redactions, u32::saturating_add);
1356    let injection: u32 = records
1357        .iter()
1358        .map(|record| record.counts.injection)
1359        .fold(overflow.injection, u32::saturating_add);
1360    let incomplete = saturating_u32(
1361        records
1362            .iter()
1363            .filter(|record| record.counts.incomplete > 0)
1364            .count(),
1365    )
1366    .saturating_add(overflow.incomplete);
1367
1368    // AP10-C4: a receipt is version 2 — its objects keyed under the
1369    // installation's receipt key. No key, no receipt: a plain digest of a
1370    // single string confirms a guess of it, so none is ever emitted again.
1371    let object_key = super::receipt_store::keyed::object_key().ok()?;
1372    let decisions: Vec<ContextDecisionV1> = decisions
1373        .into_iter()
1374        .map(|mut decision| {
1375            decision.object = object_key.object(&decision.object);
1376            decision
1377        })
1378        .collect();
1379    let outcome = if permitted == 0 {
1380        DeliveryOutcomeV1::Withheld
1381    } else if delivered.is_error {
1382        DeliveryOutcomeV1::Failed
1383    } else {
1384        DeliveryOutcomeV1::Delivered
1385    };
1386    let final_context =
1387        (outcome == DeliveryOutcomeV1::Delivered).then(|| sha256(delivered.text.as_bytes()));
1388    let principal = presenter_ref
1389        .and_then(|presenter| ProtocolReference::new(presenter).ok())
1390        .and_then(|id| PrincipalV1::new(PrincipalKindV1::Session, id).ok())
1391        .or_else(|| {
1392            identity
1393                .agent_id
1394                .and_then(|agent| reference("agent:", agent))
1395                .and_then(|id| PrincipalV1::new(PrincipalKindV1::Agent, id).ok())
1396        })
1397        .unwrap_or_else(PrincipalV1::unknown);
1398    let receipt_seed = format!(
1399        "{}|{}|{}|{:?}",
1400        policy_digest.as_str(),
1401        decisions
1402            .iter()
1403            .map(|decision| decision.object.as_str())
1404            .collect::<Vec<_>>()
1405            .join(","),
1406        final_context.as_ref().map_or("", Sha256Digest::as_str),
1407        capture.started
1408    );
1409    let receipt = ContextDecisionReceiptV1 {
1410        schema_version: RECEIPT_V2_SCHEMA_VERSION,
1411        object_digest: Some(object_key.descriptor()),
1412        receipt_id: reference(
1413            "rcpt-",
1414            &blake3::hash(receipt_seed.as_bytes()).to_hex()[..32],
1415        )?,
1416        mode,
1417        principal,
1418        task: capture
1419            .task
1420            .lock()
1421            .unwrap_or_else(PoisonError::into_inner)
1422            .as_ref()
1423            .and_then(|(task, _)| TaskId::new(task.as_str()).ok()),
1424        // An MCP call serves the host; where the host sends the context is
1425        // not known there, so locality is honestly unknown. The proxy names
1426        // the real model destination (G6).
1427        destination: identity
1428            .destination
1429            .clone()
1430            .unwrap_or_else(|| DestinationV1 {
1431                provider: ProtocolReference::new("mcp-host").expect("valid provider ref"),
1432                model: None,
1433                locality: DestinationLocalityV1::Unknown,
1434                organization_managed: false,
1435                account_ref: None,
1436                region: None,
1437            }),
1438        policy: Some(PolicyRefV1 {
1439            id: PolicyId::new("context-gateway").expect("valid policy id"),
1440            version: None,
1441            digest: policy_digest,
1442        }),
1443        sources: SourceCountsV1 {
1444            inspected: saturating_u32(decisions.len() - notes).saturating_add(overflow.objects),
1445            permitted,
1446            selected: permitted,
1447            blocked: saturating_u32(blocked).saturating_add(overflow.blocked),
1448        },
1449        decisions,
1450        security: SecurityCountsV1 {
1451            redactions,
1452            blocked_objects: saturating_u32(blocked),
1453            quarantined_objects: saturating_u32(quarantined),
1454            injection_signals: injection,
1455            incomplete_coverage: incomplete,
1456        },
1457        tokens: TokenAccountV1 {
1458            // The tool's own tokenizer measurement of its sources; a tool that
1459            // reports none delivered its sources as they were.
1460            original: capture
1461                .original_tokens
1462                .lock()
1463                .unwrap_or_else(PoisonError::into_inner)
1464                .filter(|tokens| *tokens > 0)
1465                .unwrap_or(delivered.tokens),
1466            delivered: delivered.tokens,
1467        },
1468        final_context,
1469        outcome,
1470        duration_us: u64::try_from(capture.started.elapsed().as_micros()).unwrap_or(u64::MAX),
1471        quality: None,
1472    };
1473    receipt.validate().ok()?;
1474    Some(receipt)
1475}