use std::io::Read;
use std::path::{Path, PathBuf};
use lean_ctx_protocol::context_gateway::{
ContextDecisionReceiptV1, DeliveryOutcomeV1, EgressAdmissionOutcomeV1,
EgressAdmissionReceiptV1, EgressTransportFailureV1, EgressTransportObservationV1,
EgressTransportObserverV1, EgressTransportResultV1, EgressTransportV1,
};
use lean_ctx_protocol::{MAX_EGRESS_ATTEMPTS, Sha256Digest};
use serde::{Deserialize, Serialize};
mod anchors;
pub mod capacity;
pub mod frame;
pub mod keyed;
pub mod observed;
pub mod references;
pub mod retention;
pub mod roots;
pub mod trace;
pub mod view;
pub use anchors::{Anchor, AuditAnchors, SignatureState};
pub const LATEST_CAPACITY: usize = 64;
pub(crate) const TASK_CAPACITY: usize = 256;
const MAX_INDEX_BYTES: usize = TASK_CAPACITY * 70 + 256;
const MAX_EGRESS_RECORD_BYTES: usize = 16 * 1024;
fn read_egress_record(path: &Path) -> std::io::Result<Vec<u8>> {
let mut bytes = Vec::new();
std::fs::File::open(path)?
.take((MAX_EGRESS_RECORD_BYTES + 1) as u64)
.read_to_end(&mut bytes)?;
if bytes.len() > MAX_EGRESS_RECORD_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"egress record exceeds its size limit",
));
}
Ok(bytes)
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct LatestIndex {
schema_version: u32,
receipts: Vec<String>,
}
fn gateway_dir() -> Option<PathBuf> {
crate::core::data_dir::lean_ctx_data_dir()
.ok()
.map(|dir| dir.join("gateway"))
}
pub fn store_usage(megabytes: u64) -> Option<Result<capacity::Usage, String>> {
gateway_dir().map(|dir| capacity::usage_in(&dir, megabytes))
}
fn project_key(project_root: &str) -> String {
let digest = blake3::hash(project_root.as_bytes()).to_hex();
digest[..16].to_owned()
}
fn receipt_path(dir: &Path, digest: &Sha256Digest) -> PathBuf {
dir.join("receipts")
.join(format!("{}.json.zst", digest.hex()))
}
fn plain_receipt_path(dir: &Path, digest: &Sha256Digest) -> PathBuf {
dir.join("receipts").join(format!("{}.json", digest.hex()))
}
fn egress_admission_path(dir: &Path, digest: &Sha256Digest) -> PathBuf {
dir.join("egress")
.join("admissions")
.join(format!("{}.json", digest.hex()))
}
fn egress_observation_path(dir: &Path, digest: &Sha256Digest) -> PathBuf {
dir.join("egress")
.join("observations")
.join(format!("{}.json", digest.hex()))
}
fn egress_decision_path(dir: &Path, digest: &Sha256Digest) -> PathBuf {
dir.join("egress")
.join("decisions")
.join(format!("{}.json", digest.hex()))
}
fn egress_attempt_path(dir: &Path, admission_digest: &Sha256Digest, attempt: u32) -> PathBuf {
dir.join("egress")
.join("attempts")
.join(admission_digest.hex())
.join(format!("{attempt}.json"))
}
fn latest_path(dir: &Path, project_root: &str) -> PathBuf {
dir.join("latest")
.join(format!("{}.json", project_key(project_root)))
}
fn task_path(dir: &Path, scope: &str, task_id: &str) -> PathBuf {
let mut hasher = blake3::Hasher::new();
hasher.update(scope.as_bytes());
hasher.update(&[0]);
hasher.update(task_id.as_bytes());
let digest = hasher.finalize().to_hex();
dir.join("tasks").join(format!("{}.json", &digest[..32]))
}
fn is_digest_hex(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
fn read_index(path: &Path) -> Result<LatestIndex, ()> {
use std::io::Read;
let file = match std::fs::File::open(path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(LatestIndex::default());
}
Err(_) => return Err(()),
};
let mut bytes = Vec::new();
file.take(MAX_INDEX_BYTES as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|_| ())?;
if bytes.len() > MAX_INDEX_BYTES {
return Err(());
}
let index: LatestIndex = serde_json::from_slice(&bytes).map_err(|_| ())?;
if index.schema_version != 1
|| index.receipts.len() > TASK_CAPACITY
|| !index.receipts.iter().all(|hex| is_digest_hex(hex))
{
return Err(());
}
Ok(index)
}
pub(crate) fn write_atomic(dir: &Path, path: &Path, bytes: &[u8]) -> Result<(), String> {
write_atomic_as(dir, path, bytes, capacity::Kind::FollowUp)
}
fn write_atomic_as(
dir: &Path,
path: &Path,
bytes: &[u8],
kind: capacity::Kind,
) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
capacity::write(dir, path, bytes.len() as u64, kind, || {
crate::core::atomic_fs::try_atomic_write_unswept(path, bytes).map_err(|e| e.to_string())
})
}
pub(crate) fn write_new(dir: &Path, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
if path.exists() {
return Err(std::io::ErrorKind::AlreadyExists.into());
}
let mut exists = false;
capacity::write(
dir,
path,
bytes.len() as u64,
capacity::Kind::FollowUp,
|| {
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.and_then(|mut file| file.write_all(bytes).and_then(|()| file.sync_all()))
.map_err(|error| {
exists = error.kind() == std::io::ErrorKind::AlreadyExists;
error.to_string()
})
},
)
.map_err(|message| {
if exists {
std::io::ErrorKind::AlreadyExists.into()
} else {
std::io::Error::other(message)
}
})
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct EgressAttemptPointerV1 {
schema_version: u32,
observation_digest: Sha256Digest,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct EgressDecisionPointerV1 {
schema_version: u32,
admission_digest: Sha256Digest,
}
#[derive(Debug, Clone)]
pub struct StoredEgressAdmission {
pub digest: Sha256Digest,
pub admission: EgressAdmissionReceiptV1,
}
#[derive(Debug, Clone)]
pub struct StoredEgressObservation {
pub(crate) digest: Sha256Digest,
pub observation: EgressTransportObservationV1,
}
#[derive(Debug, Clone)]
pub struct EgressLifecycleView {
pub admission: Option<StoredEgressAdmission>,
pub observations: Vec<StoredEgressObservation>,
}
fn evidence_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>, &'static str> {
let value = serde_json::to_value(value).map_err(|_| "egress evidence serialization failed")?;
serde_json::to_vec(&value).map_err(|_| "egress evidence serialization failed")
}
fn write_once(dir: &Path, path: &Path, bytes: &[u8]) -> Result<(), &'static str> {
write_new(dir, path, bytes).map_err(|error| {
if error.kind() == std::io::ErrorKind::AlreadyExists {
"egress evidence record already exists"
} else {
tracing::warn!("egress evidence record not written: {error}");
"egress evidence store unavailable"
}
})
}
pub(crate) fn lock_task_files(dir: &Path) -> Option<std::fs::File> {
std::fs::create_dir_all(dir).ok()?;
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(dir.join(".index.lock"))
.ok()?;
crate::core::file_lock::acquire_exclusive_timeout(&file, LOCK_WAIT)
.ok()
.map(|()| file)
}
const LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
fn incomplete_marker(dir: &Path, scope: &str, task_id: &str) -> PathBuf {
task_path(dir, scope, task_id).with_extension("incomplete")
}
pub(crate) fn persist(
receipt: &ContextDecisionReceiptV1,
project_root: &str,
task_scope: Option<&str>,
) -> Result<Sha256Digest, String> {
let dir = gateway_dir().ok_or("no lean-ctx data directory")?;
let persisted = persist_in(&dir, receipt, project_root, task_scope);
retention::maintain_in_background(dir, false);
persisted.inspect_err(|error| tracing::warn!("gateway receipt not persisted: {error}"))
}
pub(crate) fn persist_in(
dir: &Path,
receipt: &ContextDecisionReceiptV1,
project_root: &str,
task_scope: Option<&str>,
) -> Result<Sha256Digest, String> {
let digest = receipt.canonical_digest().map_err(|e| e.to_string())?;
let value = serde_json::to_value(receipt).map_err(|e| e.to_string())?;
let bytes = serde_json::to_vec(&value).map_err(|e| e.to_string())?;
if bytes.len() as u64 > retention::MAX_RECEIPT_BYTES {
return Err(format!(
"gateway receipt exceeds its size bound ({} MB)",
retention::MAX_RECEIPT_BYTES / 1_048_576
));
}
let path = receipt_path(dir, &digest);
if path.exists() || plain_receipt_path(dir, &digest).exists() {
load_in(dir, digest.hex()).map_err(|_| "existing gateway receipt is invalid".to_owned())?;
} else {
write_atomic_as(dir, &path, &retention::pack(&bytes)?, capacity::Kind::Entry)?;
}
let Some(_lock) = lock_task_files(dir) else {
if let (Some(task), Some(scope)) = (&receipt.task, task_scope) {
let marker = incomplete_marker(dir, scope, task.as_str());
if let Err(error) = capacity::mark(dir, &marker) {
tracing::warn!("task receipt index skipped and not marked incomplete: {error}");
}
}
return Ok(digest);
};
let project_root = roots::canonical(project_root);
roots::register(dir, &project_root);
let index_path = latest_path(dir, &project_root);
let mut index = read_index(&index_path).unwrap_or_default();
index.schema_version = 1;
index.receipts.retain(|existing| existing != digest.hex());
index.receipts.insert(0, digest.hex().to_owned());
index.receipts.truncate(LATEST_CAPACITY);
write_atomic(
dir,
&index_path,
&serde_json::to_vec(&index).map_err(|e| e.to_string())?,
)?;
if let Some(output) = &receipt.final_context {
index_output(dir, output, &digest);
}
if let (Some(task), Some(scope)) = (&receipt.task, task_scope) {
let task_index_path = task_path(dir, scope, task.as_str());
if let Ok(mut task_index) = read_index(&task_index_path)
&& task_index.receipts.len() < TASK_CAPACITY
&& !task_index.receipts.iter().any(|hex| hex == digest.hex())
{
task_index.schema_version = 1;
task_index.receipts.push(digest.hex().to_owned());
write_atomic(
dir,
&task_index_path,
&serde_json::to_vec(&task_index).map_err(|e| e.to_string())?,
)?;
}
}
Ok(digest)
}
#[derive(Debug, Clone)]
pub struct StoredReceipt {
pub digest: Sha256Digest,
pub receipt: ContextDecisionReceiptV1,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoadError {
Missing,
Unreadable,
Tampered,
}
pub(crate) fn load_in(dir: &Path, hex: &str) -> Result<StoredReceipt, LoadError> {
let expected = Sha256Digest::new(format!("sha256:{hex}")).map_err(|_| LoadError::Unreadable)?;
let bytes = if let Ok(file) = std::fs::File::open(receipt_path(dir, &expected)) {
retention::unpack(file).map_err(|_| LoadError::Unreadable)?
} else {
let file = std::fs::File::open(plain_receipt_path(dir, &expected))
.map_err(|_| LoadError::Missing)?;
let mut bytes = Vec::new();
file.take(retention::MAX_RECEIPT_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|_| LoadError::Unreadable)?;
if bytes.len() as u64 > retention::MAX_RECEIPT_BYTES {
return Err(LoadError::Unreadable);
}
bytes
};
let receipt: ContextDecisionReceiptV1 =
serde_json::from_slice(&bytes).map_err(|_| LoadError::Unreadable)?;
let actual = receipt
.canonical_digest()
.map_err(|_| LoadError::Tampered)?;
if actual != expected {
return Err(LoadError::Tampered);
}
Ok(StoredReceipt {
digest: expected,
receipt,
})
}
fn validate_admission_link(
dir: &Path,
admission: &EgressAdmissionReceiptV1,
) -> Result<(), &'static str> {
admission
.validate()
.map_err(|_| "egress admission is invalid")?;
let Some(decision_digest) = admission.decision_receipt_digest.as_ref() else {
return if admission.outcome == EgressAdmissionOutcomeV1::Failed {
Ok(())
} else {
Err("egress admission is missing its decision receipt")
};
};
let decision = load_in(dir, decision_digest.hex())
.map_err(|_| "linked egress decision receipt unavailable")?;
if decision.digest != *decision_digest {
return Err("linked egress decision receipt digest mismatch");
}
let linked = &decision.receipt;
let consistent = match admission.outcome {
EgressAdmissionOutcomeV1::Prepared => {
linked.outcome == DeliveryOutcomeV1::Delivered
&& linked.final_context == admission.prepared_bytes_digest
}
EgressAdmissionOutcomeV1::Withheld => {
matches!(
linked.outcome,
DeliveryOutcomeV1::Withheld | DeliveryOutcomeV1::Failed
) && linked.final_context.is_none()
}
EgressAdmissionOutcomeV1::Failed => {
linked.outcome == DeliveryOutcomeV1::Failed && linked.final_context.is_none()
}
};
if consistent {
Ok(())
} else {
Err("egress admission does not match its decision receipt")
}
}
pub(crate) fn persist_admission(
admission: &EgressAdmissionReceiptV1,
) -> Result<Sha256Digest, &'static str> {
let dir = gateway_dir().ok_or("egress evidence store unavailable")?;
persist_admission_in(&dir, admission)
}
pub(crate) fn persist_admission_in(
dir: &Path,
admission: &EgressAdmissionReceiptV1,
) -> Result<Sha256Digest, &'static str> {
validate_admission_link(dir, admission)?;
let digest = admission
.canonical_digest()
.map_err(|_| "egress admission is invalid")?;
let bytes = evidence_bytes(admission)?;
let path = egress_admission_path(dir, &digest);
if path.exists() {
let stored = load_admission_in(dir, &digest)?;
if stored.admission != *admission {
return Err("egress admission digest collision");
}
} else {
write_atomic_as(dir, &path, &bytes, capacity::Kind::Entry).map_err(|error| {
tracing::warn!("egress admission not persisted: {error}");
"egress evidence store unavailable"
})?;
let stored = load_admission_in(dir, &digest)?;
if stored.admission != *admission {
return Err("egress admission verification failed");
}
}
if let Some(decision_digest) = admission.decision_receipt_digest.as_ref() {
let Some(_lock) = lock_task_files(dir) else {
return Err("egress evidence store lock unavailable");
};
let pointer_path = egress_decision_path(dir, decision_digest);
match read_decision_pointer(&pointer_path)? {
Some(pointer) if pointer.admission_digest == digest => {}
Some(_) => return Err("decision receipt already has another egress admission"),
None => {
let pointer = EgressDecisionPointerV1 {
schema_version: 1,
admission_digest: digest.clone(),
};
write_once(dir, &pointer_path, &evidence_bytes(&pointer)?)?;
}
}
}
Ok(digest)
}
fn read_decision_pointer(path: &Path) -> Result<Option<EgressDecisionPointerV1>, &'static str> {
let bytes = match read_egress_record(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(_) => return Err("egress evidence store unavailable"),
};
let pointer: EgressDecisionPointerV1 =
serde_json::from_slice(&bytes).map_err(|_| "egress decision link is invalid")?;
if pointer.schema_version != 1 {
return Err("egress decision link has an unsupported version");
}
Ok(Some(pointer))
}
pub(crate) fn load_admission_in(
dir: &Path,
digest: &Sha256Digest,
) -> Result<StoredEgressAdmission, &'static str> {
let bytes = read_egress_record(&egress_admission_path(dir, digest))
.map_err(|_| "egress admission unavailable")?;
let admission: EgressAdmissionReceiptV1 =
serde_json::from_slice(&bytes).map_err(|_| "egress admission is unreadable")?;
let actual = admission
.canonical_digest()
.map_err(|_| "egress admission is invalid")?;
if actual != *digest {
return Err("egress admission digest mismatch");
}
validate_admission_link(dir, &admission)?;
Ok(StoredEgressAdmission {
digest: actual,
admission,
})
}
fn read_attempt_pointer(path: &Path) -> Result<Option<EgressAttemptPointerV1>, &'static str> {
let bytes = match read_egress_record(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(_) => return Err("egress evidence store unavailable"),
};
let pointer: EgressAttemptPointerV1 =
serde_json::from_slice(&bytes).map_err(|_| "egress attempt link is invalid")?;
if pointer.schema_version != 1 {
return Err("egress attempt link has an unsupported version");
}
Ok(Some(pointer))
}
fn load_observation_in(
dir: &Path,
digest: &Sha256Digest,
admission: &EgressAdmissionReceiptV1,
) -> Result<StoredEgressObservation, &'static str> {
let bytes = read_egress_record(&egress_observation_path(dir, digest))
.map_err(|_| "egress observation unavailable")?;
let observation: EgressTransportObservationV1 =
serde_json::from_slice(&bytes).map_err(|_| "egress observation is unreadable")?;
let actual = observation
.canonical_digest()
.map_err(|_| "egress observation is invalid")?;
if actual != *digest {
return Err("egress observation digest mismatch");
}
observation
.validate_for(admission)
.map_err(|_| "egress observation does not match its admission")?;
Ok(StoredEgressObservation {
digest: actual,
observation,
})
}
fn persist_observation_object_in(
dir: &Path,
observation: &EgressTransportObservationV1,
) -> Result<Sha256Digest, &'static str> {
let digest = observation
.canonical_digest()
.map_err(|_| "egress observation is invalid")?;
let bytes = evidence_bytes(observation)?;
let path = egress_observation_path(dir, &digest);
if path.exists() {
let admission = load_admission_in(dir, &observation.admission_receipt_digest)?;
let stored = load_observation_in(dir, &digest, &admission.admission)?;
if stored.observation != *observation {
return Err("egress observation digest collision");
}
} else {
write_atomic(dir, &path, &bytes).map_err(|_| "egress evidence store unavailable")?;
}
Ok(digest)
}
fn append_observation_in(
dir: &Path,
observation: &EgressTransportObservationV1,
) -> Result<Sha256Digest, &'static str> {
if observation.attempt > MAX_EGRESS_ATTEMPTS {
return Err("egress observation attempt exceeds the limit");
}
let Some(_lock) = lock_task_files(dir) else {
return Err("egress evidence store lock unavailable");
};
let admission = load_admission_in(dir, &observation.admission_receipt_digest)?;
observation
.validate_for(&admission.admission)
.map_err(|_| "egress observation does not match a prepared admission")?;
let digest = observation
.canonical_digest()
.map_err(|_| "egress observation is invalid")?;
let path = egress_attempt_path(
dir,
&observation.admission_receipt_digest,
observation.attempt,
);
if let Some(pointer) = read_attempt_pointer(&path)? {
let stored = load_observation_in(dir, &pointer.observation_digest, &admission.admission)?;
if stored.observation == *observation && stored.digest == digest {
Ok(digest)
} else {
Err("egress attempt already has a different observation")
}
} else {
persist_observation_object_in(dir, observation)?;
let pointer = EgressAttemptPointerV1 {
schema_version: 1,
observation_digest: digest.clone(),
};
write_once(dir, &path, &evidence_bytes(&pointer)?)?;
Ok(digest)
}
}
#[cfg(feature = "http-server")]
pub(crate) fn append_engine_observation(
observation: &EgressTransportObservationV1,
) -> Result<(), &'static str> {
let dir = gateway_dir().ok_or("egress evidence store unavailable")?;
append_engine_observation_in(&dir, observation)
}
#[cfg(feature = "http-server")]
pub(crate) fn append_engine_observation_in(
dir: &Path,
observation: &EgressTransportObservationV1,
) -> Result<(), &'static str> {
if observation.observer != EgressTransportObserverV1::EngineObserved {
return Err("Engine observations must be EngineObserved");
}
append_observation_in(dir, observation).map(|_| ())
}
pub(crate) fn append_host_observation(
admission_digest: &Sha256Digest,
attempt: u32,
transport: EgressTransportV1,
result: EgressTransportResultV1,
http_status: Option<u16>,
failure: Option<EgressTransportFailureV1>,
) -> Result<(Sha256Digest, EgressTransportObservationV1), &'static str> {
let dir = gateway_dir().ok_or("egress evidence store unavailable")?;
append_host_observation_in(
&dir,
admission_digest,
attempt,
transport,
result,
http_status,
failure,
)
}
pub(crate) fn append_host_observation_in(
dir: &Path,
admission_digest: &Sha256Digest,
attempt: u32,
transport: EgressTransportV1,
result: EgressTransportResultV1,
http_status: Option<u16>,
failure: Option<EgressTransportFailureV1>,
) -> Result<(Sha256Digest, EgressTransportObservationV1), &'static str> {
let admission = load_admission_in(dir, admission_digest)?;
if admission.admission.outcome != EgressAdmissionOutcomeV1::Prepared {
return Err("host reports require a prepared admission");
}
let observation = EgressTransportObservationV1 {
schema_version: 1,
admission_receipt_digest: admission_digest.clone(),
attempt,
transport,
observer: EgressTransportObserverV1::HostReported,
result,
http_status,
failure,
};
let digest = append_observation_in(dir, &observation)?;
Ok((digest, observation))
}
pub(crate) fn load_egress_for_decision(
decision_digest: &Sha256Digest,
) -> Result<Option<EgressLifecycleView>, &'static str> {
let dir = gateway_dir().ok_or("egress evidence store unavailable")?;
load_egress_for_decision_in(&dir, decision_digest)
}
pub(crate) fn load_egress_for_decision_in(
dir: &Path,
decision_digest: &Sha256Digest,
) -> Result<Option<EgressLifecycleView>, &'static str> {
let decision =
load_in(dir, decision_digest.hex()).map_err(|_| "decision receipt unavailable")?;
let pointer_path = egress_decision_path(dir, decision_digest);
let Some(pointer) = read_decision_pointer(&pointer_path)? else {
if decision.receipt.destination.provider.as_str() == "mcp-host" {
return Ok(None);
}
return Ok(Some(EgressLifecycleView {
admission: None,
observations: Vec::new(),
}));
};
let admission = load_admission_in(dir, &pointer.admission_digest)?;
if admission.admission.decision_receipt_digest.as_ref() != Some(decision_digest) {
return Err("egress decision link does not match its admission");
}
let mut observations = Vec::new();
if admission.admission.outcome == EgressAdmissionOutcomeV1::Prepared {
for attempt in 1..=MAX_EGRESS_ATTEMPTS {
let path = egress_attempt_path(dir, &admission.digest, attempt);
let Some(pointer) = read_attempt_pointer(&path)? else {
continue;
};
let stored =
load_observation_in(dir, &pointer.observation_digest, &admission.admission)?;
if stored.observation.attempt != attempt {
return Err("egress attempt link names a different attempt");
}
observations.push(stored);
}
}
Ok(Some(EgressLifecycleView {
admission: Some(admission),
observations,
}))
}
pub fn latest(project_root: &str, limit: usize) -> Vec<(String, Result<StoredReceipt, LoadError>)> {
let Some(dir) = gateway_dir() else {
return Vec::new();
};
latest_in(&dir, project_root, limit)
}
pub(crate) fn latest_in(
dir: &Path,
project_root: &str,
limit: usize,
) -> Vec<(String, Result<StoredReceipt, LoadError>)> {
roots::latest_in(dir, project_root, limit)
}
#[derive(Debug, Clone)]
pub struct TaskReceipts {
pub entries: Vec<(String, Result<StoredReceipt, LoadError>)>,
pub index_complete: bool,
}
impl TaskReceipts {
pub(crate) fn complete(&self) -> bool {
self.index_complete && self.entries.iter().all(|(_, loaded)| loaded.is_ok())
}
pub(crate) fn verified(&self) -> impl Iterator<Item = &ContextDecisionReceiptV1> {
self.entries
.iter()
.filter_map(|(_, loaded)| loaded.as_ref().ok().map(|stored| &stored.receipt))
}
}
pub(crate) fn for_task(scope: &str, task_id: &str) -> TaskReceipts {
match gateway_dir() {
Some(dir) => for_task_in(&dir, scope, task_id),
None => TaskReceipts {
entries: Vec::new(),
index_complete: true,
},
}
}
pub(crate) fn for_task_in(dir: &Path, scope: &str, task_id: &str) -> TaskReceipts {
let Ok(index) = read_index(&task_path(dir, scope, task_id)) else {
return TaskReceipts {
entries: Vec::new(),
index_complete: false,
};
};
let index_complete =
index.receipts.len() < TASK_CAPACITY && !incomplete_marker(dir, scope, task_id).exists();
let entries = index
.receipts
.into_iter()
.map(|hex| {
let loaded = load_in(dir, &hex).and_then(|stored| {
if stored
.receipt
.task
.as_ref()
.map(lean_ctx_protocol::TaskId::as_str)
== Some(task_id)
{
Ok(stored)
} else {
Err(LoadError::Tampered)
}
});
(hex, loaded)
})
.collect();
TaskReceipts {
entries,
index_complete,
}
}
pub fn load(hex: &str) -> Result<StoredReceipt, LoadError> {
let dir = gateway_dir().ok_or(LoadError::Missing)?;
if !is_digest_hex(hex) {
return Err(LoadError::Unreadable);
}
load_in(&dir, hex)
}
const OUTPUT_CAPACITY: usize = 16;
fn output_path(dir: &Path, output: &Sha256Digest) -> PathBuf {
dir.join("outputs").join(format!("{}.json", output.hex()))
}
fn index_output(dir: &Path, output: &Sha256Digest, receipt: &Sha256Digest) {
let path = output_path(dir, output);
let Ok(mut index) = read_index(&path) else {
return;
};
if index.receipts.iter().any(|hex| hex == receipt.hex()) {
return;
}
index.schema_version = 1;
index.receipts.insert(0, receipt.hex().to_owned());
index.receipts.truncate(OUTPUT_CAPACITY);
if let Ok(bytes) = serde_json::to_vec(&index)
&& let Err(error) = write_atomic(dir, &path, &bytes)
{
tracing::warn!("gateway receipt not indexed by its output: {error}");
}
}
pub fn output_digest(bytes: &[u8]) -> Sha256Digest {
use sha2::{Digest, Sha256};
let hex = crate::core::agent_identity::hex_encode(&Sha256::digest(bytes));
Sha256Digest::new(format!("sha256:{hex}")).expect("SHA-256 digest is canonical")
}
pub fn by_output(
project_root: &str,
output: &Sha256Digest,
) -> Vec<(String, Result<StoredReceipt, LoadError>)> {
let Some(dir) = gateway_dir() else {
return Vec::new();
};
by_output_in(&dir, project_root, output)
}
pub(crate) fn by_output_in(
dir: &Path,
project_root: &str,
output: &Sha256Digest,
) -> Vec<(String, Result<StoredReceipt, LoadError>)> {
let indexed: Vec<_> = read_index(&output_path(dir, output))
.unwrap_or_default()
.receipts
.into_iter()
.map(|hex| {
let loaded = load_in(dir, &hex).and_then(|stored| {
if stored.receipt.final_context.as_ref() == Some(output) {
Ok(stored)
} else {
Err(LoadError::Tampered)
}
});
(hex, loaded)
})
.collect();
if !indexed.is_empty() {
return indexed;
}
latest_in(dir, project_root, LATEST_CAPACITY)
.into_iter()
.filter(|(_, loaded)| {
loaded
.as_ref()
.is_ok_and(|stored| stored.receipt.final_context.as_ref() == Some(output))
})
.collect()
}
pub const NOT_PERSISTED_CODE: &str = "receipt.not_persisted";
pub(crate) fn mandatory_under(
policy: &super::AdmissionPolicy,
destination_policy_active: bool,
) -> bool {
super::stored::source_authority_required_under(policy) || destination_policy_active
}
#[derive(Debug, Clone)]
pub struct Duty {
pub mandatory: bool,
snapshot: super::capture::CallSnapshot,
}
impl Duty {
pub fn at_entry() -> Self {
Self::under(super::capture::CallSnapshot::take())
}
pub fn of(capture: &super::capture::AdmissionCapture) -> Self {
capture
.snapshot()
.cloned()
.map_or_else(Self::at_entry, Self::under)
}
fn under(snapshot: super::capture::CallSnapshot) -> Self {
Self {
mandatory: snapshot.protected(),
snapshot,
}
}
pub fn snapshot(&self) -> &super::capture::CallSnapshot {
&self.snapshot
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContentFree {
NoBytes,
GatewayNotice,
}
impl ContentFree {
fn of(text: &str) -> Option<Self> {
let text = text.strip_suffix('\n').unwrap_or(text);
if text.is_empty() {
return Some(Self::NoBytes);
}
let codes = text
.strip_prefix("[lean-ctx gateway: content withheld — ")?
.strip_suffix(']')?;
let typed = !codes.is_empty()
&& codes
.bytes()
.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'.' | b'-' | b',' | b' '));
typed.then_some(Self::GatewayNotice)
}
}
pub struct ReceiptSubject<'a> {
pub tool: &'a str,
pub agent_id: Option<&'a str>,
pub presenter_ref: Option<&'a str>,
pub project_root: &'a str,
pub delivery: &'a trace::Delivery,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReceiptRecord {
NotOwed,
ContentFree(ContentFree),
Stored {
digest: Sha256Digest,
security: bool,
prepared: Option<observed::Prepared>,
},
Missing { security: bool },
}
impl ReceiptRecord {
pub fn security(&self) -> bool {
match self {
Self::NotOwed | Self::ContentFree(_) => false,
Self::Stored { security, .. } | Self::Missing { security } => *security,
}
}
pub fn into_prepared(self) -> Option<observed::Prepared> {
match self {
Self::Stored { prepared, .. } => prepared,
Self::NotOwed | Self::ContentFree(_) | Self::Missing { .. } => None,
}
}
pub fn summary(&self) -> String {
match self {
Self::NotOwed => "not owed".to_owned(),
Self::ContentFree(reason) => format!("not owed, content-free result ({reason:?})"),
Self::Stored { digest, .. } => format!("stored {}", digest.as_str()),
Self::Missing { .. } => "owed and missing".to_owned(),
}
}
pub fn withholds(&self, mandatory: bool) -> bool {
mandatory && matches!(self, Self::Missing { .. })
}
}
pub fn not_persisted_notice() -> String {
super::carrier::Withheld::new(&[NOT_PERSISTED_CODE]).notice()
}
fn anchor_pair(
tool: &str,
agent_id: &str,
receipt: &Sha256Digest,
trace_hex: &str,
) -> Result<String, String> {
let session = crate::core::value::current_session()
.map(|id| format!("|session={id}"))
.unwrap_or_default();
crate::core::audit_trail::try_record(crate::core::audit_trail::AuditEntryData {
agent_id: agent_id.to_owned(),
tool: tool.to_owned(),
action: Some(format!(
"gateway_receipt:{}|trace={trace_hex}{session}",
receipt.hex()
)),
input_hash: String::new(),
output_tokens: 0,
role: crate::core::roles::active_role_name(),
event_type: crate::core::audit_trail::AuditEventType::ToolCall,
})
.map(|entry| entry.entry_hash)
.map_err(|error| format!("gateway receipt not anchored: {error}"))
}
pub(super) struct Sealing<'a> {
pub(super) capture: &'a super::capture::AdmissionCapture,
pub(super) delivery: &'a trace::Delivery,
pub(super) delivered: &'a super::capture::Delivered<'a>,
pub(super) tool: &'a str,
pub(super) audit_agent: &'a str,
}
pub(super) fn seal(
digest: &Sha256Digest,
receipt: &ContextDecisionReceiptV1,
sealing: &Sealing<'_>,
) -> Result<observed::Prepared, String> {
use observed::PreparationStep as Step;
load(digest.hex())
.map_err(|_| {
(
Step::Receipt,
"stored gateway receipt does not verify".to_owned(),
)
})
.and_then(|_| {
trace::build(
digest,
receipt,
sealing.capture,
sealing.delivery,
sealing.delivered,
)
.and_then(|sidecar| trace::persist(&sidecar))
.map_err(|error| (Step::Sidecar, error))
})
.and_then(|trace_hex| {
anchor_pair(sealing.tool, sealing.audit_agent, digest, &trace_hex)
.map_err(|error| (Step::Anchor, error))
})
.map(|anchor_entry| {
observed::Prepared::sealed(
digest,
anchor_entry,
sealing.delivery.channel(),
sealing.delivery.prepared_bytes(sealing.delivered),
)
})
.map_err(|(step, error)| {
observed::preparation_failed(digest, step);
error
})
}
pub fn record_delivery(
capture: &super::capture::AdmissionCapture,
subject: &ReceiptSubject<'_>,
delivered: &super::capture::Delivered<'_>,
duty: &Duty,
) -> ReceiptRecord {
let mandatory = duty.mandatory;
if !capture.describes_sources() {
if !mandatory {
return ReceiptRecord::NotOwed;
}
if let Some(reason) = ContentFree::of(delivered.text) {
return ReceiptRecord::ContentFree(reason);
}
let policy = duty.snapshot.admission().admission_policy();
capture.record_unsourced(
output_digest(delivered.text.as_bytes()),
policy.gateway.mode,
super::fingerprint_digest(&policy.fingerprint()),
);
}
let audit_agent = subject.agent_id.unwrap_or("unknown");
let tally = capture.security_tally();
let identity = super::capture::CallIdentity {
agent_id: subject.agent_id,
destination: None,
};
let stored =
super::capture::finish_for_presenter(capture, &identity, subject.presenter_ref, delivered)
.ok_or_else(|| "no valid receipt could be built".to_owned())
.and_then(|receipt| {
capture.record_security(subject.tool, audit_agent);
let digest = persist(
&receipt,
subject.project_root,
capture.task_scope().as_deref(),
)?;
if !mandatory {
return Ok((digest, None));
}
let sealing = Sealing {
capture,
delivery: subject.delivery,
delivered,
tool: subject.tool,
audit_agent,
};
let prepared = seal(&digest, &receipt, &sealing)?;
Ok((digest, Some(prepared)))
});
match stored {
Ok((digest, prepared)) => {
if prepared.is_none() && !tally.is_empty() {
crate::core::security_events::anchor_receipt(
subject.tool,
audit_agent,
digest.hex(),
);
}
ReceiptRecord::Stored {
digest,
security: !tally.is_empty(),
prepared,
}
}
Err(error) => {
tracing::warn!("context gateway receipt not persisted: {error}");
if mandatory {
crate::core::security_events::record_one(
subject.tool,
audit_agent,
crate::core::security_events::SecurityKind::ContentWithheld,
);
}
ReceiptRecord::Missing {
security: mandatory || !tally.is_empty(),
}
}
}
}
#[cfg(test)]
#[path = "receipt_store_egress_size_tests.rs"]
mod egress_record_size_tests;