1use 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
29pub 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#[derive(Debug, Clone)]
49pub struct CallSnapshot {
50 admission: super::stores::StoreAdmission,
51 protected: bool,
52 pack: Option<(String, String)>,
53 org_policy: Option<OrgObservation>,
56}
57
58pub(crate) type OrgObservation = crate::core::policy::org::Observation;
60
61#[derive(Debug, Clone, PartialEq, Eq, Default)]
63pub enum OrgEvidence {
64 #[default]
66 None,
67 One(OrgObservation),
69 Diverged,
74 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 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 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 pub fn admission(&self) -> &super::stores::StoreAdmission {
115 &self.admission
116 }
117
118 pub(crate) fn protected(&self) -> bool {
121 self.protected
122 }
123
124 pub(crate) fn org_policy(&self) -> OrgEvidence {
126 OrgEvidence::of(self.org_policy.clone())
127 }
128
129 pub(crate) fn pack(&self) -> Option<&(String, String)> {
131 self.pack.as_ref()
132 }
133}
134
135pub(crate) const MAX_SOURCE_REFERENCES: usize = 64;
137pub(crate) const MAX_SOURCE_REFERENCE_BYTES: usize = 256;
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum SourceName {
143 Path(String),
145 Id(String),
148}
149
150#[derive(Debug, Default)]
152struct SourceNotes {
153 named: Vec<(Sha256Digest, SourceName)>,
156 omitted: u32,
158}
159
160thread_local! {
161 static UNNAMED: std::cell::RefCell<Option<(usize, Sha256Digest)>> =
167 const { std::cell::RefCell::new(None) };
168}
169
170pub(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
200pub 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
215fn 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#[derive(Debug)]
227pub struct AdmissionCapture {
228 started: Instant,
229 records: Mutex<Vec<Recorded>>,
230 overflow: Mutex<Overflow>,
232 original_tokens: Mutex<Option<u64>>,
234 policy: Mutex<Option<(GatewayModeV1, Sha256Digest)>>,
235 task: Mutex<Option<(String, String)>>,
238 snapshot: Option<CallSnapshot>,
242 diverged: std::sync::atomic::AtomicBool,
245 sources: Mutex<SourceNotes>,
246 pack: Mutex<Option<(String, String)>>,
248 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 pub fn for_call(snapshot: CallSnapshot) -> Arc<Self> {
279 Arc::new(Self {
280 snapshot: Some(snapshot),
281 ..Self::default()
282 })
283 }
284
285 pub fn snapshot(&self) -> Option<&CallSnapshot> {
287 self.snapshot.as_ref()
288 }
289
290 pub(crate) fn policy_diverged(&self) -> bool {
293 self.diverged.load(std::sync::atomic::Ordering::SeqCst)
294 }
295
296 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 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 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 pub(super) fn note_org_policy(&self, observation: Option<OrgObservation>) {
327 self.merge_org_policy(OrgEvidence::of(observation));
328 }
329
330 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 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 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 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 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 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 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 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 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 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 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 pub(crate) fn counted_only(&self) -> u32 {
524 self.overflow
525 .lock()
526 .unwrap_or_else(PoisonError::into_inner)
527 .objects
528 }
529
530 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 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 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 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 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 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 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 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
672pub(super) const NOTES_NOT_ITEMIZED: &str = "recovery.notes_not_itemized";
677
678pub(crate) const NO_SOURCE_DECISION: &str = "delivery.no_source_decision";
682
683fn 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 fn is_clean(&self) -> bool {
729 self.decision.disposition == ContextDispositionV1::Allow
730 && self.decision.reason_codes.is_empty()
731 }
732
733 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 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#[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 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
825pub struct CallIdentity<'a> {
827 pub agent_id: Option<&'a str>,
828 pub destination: Option<DestinationV1>,
830}
831
832pub 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 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
860pub(super) fn note_object() -> Sha256Digest {
866 sha256(b"proxy-block-kept-whole")
867}
868
869pub fn is_note(decision: &ContextDecisionV1) -> bool {
872 let note = note_object();
873 decision.object == note
875 || super::receipt_store::keyed::object_key()
876 .is_ok_and(|key| decision.object == key.object(¬e))
877}
878
879#[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
892pub(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 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 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 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 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 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}