Skip to main content

aidens_receipts/
lib.rs

1//! Thin persistence wiring for canonical receipt crates.
2//!
3//! AiDENs does not define receipt semantics here. This crate only gives the
4//! product layer a small JSONL sink for receipts whose schemas are owned by
5//! canonical libraries, plus orchestration reports that are explicitly labeled
6//! as AiDENs-owned reports rather than stack receipts.
7
8use async_trait::async_trait;
9use chrono::Utc;
10pub use llm_tool_runtime::{
11    ToolError, ToolErrorClass, ToolReceipt as CanonicalRuntimeToolReceipt, ToolReceiptSink,
12};
13pub use semantic_memory_forge::ForgeToolReceiptV2 as CanonicalForgeToolReceiptV2;
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use stack_ids::ContentDigest;
17use std::fs::{File, OpenOptions};
18use std::io::{BufRead, BufReader, Write};
19use std::path::{Path, PathBuf};
20use std::thread;
21use std::time::{Duration, Instant};
22use thiserror::Error;
23pub use verification_control::ControlReceipt as CanonicalControlReceipt;
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct RunBundleStoreConfig {
27    pub root_path: PathBuf,
28    pub bundles_path: PathBuf,
29    pub index_path: PathBuf,
30}
31
32impl RunBundleStoreConfig {
33    pub fn for_receipt_root(root_path: impl Into<PathBuf>) -> Self {
34        let root_path = root_path.into();
35        let bundles_path = root_path.join("run-bundles");
36        Self {
37            index_path: bundles_path.join("index.ndjson"),
38            bundles_path,
39            root_path,
40        }
41    }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct RunBundleStoreRecord {
46    pub artifact_kind: String,
47    pub ownership: String,
48    pub support_tier: String,
49    pub semantic_status: String,
50    pub run_id: String,
51    pub bundle_schema: String,
52    pub recorded_at: String,
53    pub bundle_path: PathBuf,
54    pub content_digest: ContentDigest,
55    pub canonical_event_log_path: PathBuf,
56    pub known_limits: Vec<String>,
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60pub struct RunBundleStoreInspection {
61    pub record: RunBundleStoreRecord,
62    pub bundle: Value,
63    pub digest_verified: bool,
64}
65
66#[derive(Debug, Error)]
67pub enum RunBundleStoreError {
68    #[error("run bundle store io error at {path}: {source}")]
69    Io {
70        path: PathBuf,
71        #[source]
72        source: std::io::Error,
73    },
74    #[error("run bundle store json error at {path}: {source}")]
75    Json {
76        path: PathBuf,
77        #[source]
78        source: serde_json::Error,
79    },
80    #[error("run bundle digest error: {source}")]
81    Digest {
82        #[source]
83        source: stack_ids::DigestError,
84    },
85    #[error("run bundle store requires AiDENsRunBundleV3, got {0}")]
86    UnsupportedSchema(String),
87    #[error("run bundle missing string run_id")]
88    MissingRunId,
89    #[error("run bundle not found: {0}")]
90    NotFound(String),
91    #[error("run bundle store has multiple bundles; pass a specific run output directory or bundle path")]
92    AmbiguousBundle,
93    #[error("run bundle already exists at {0}")]
94    AlreadyExists(String),
95    #[error("run bundle index chain verification failed at sequence {0}")]
96    ChainVerificationFailed(u64),
97}
98
99#[derive(Debug, Clone)]
100pub struct RunBundleStore {
101    config: RunBundleStoreConfig,
102}
103
104impl RunBundleStore {
105    pub fn open(config: RunBundleStoreConfig) -> Result<Self, RunBundleStoreError> {
106        std::fs::create_dir_all(&config.bundles_path).map_err(|source| {
107            RunBundleStoreError::Io {
108                path: config.bundles_path.clone(),
109                source,
110            }
111        })?;
112        ensure_run_bundle_file(&config.index_path)?;
113        Ok(Self { config })
114    }
115
116    pub fn config(&self) -> &RunBundleStoreConfig {
117        &self.config
118    }
119
120    pub fn bundle_path_for_run_id(&self, run_id: &str) -> PathBuf {
121        self.config
122            .bundles_path
123            .join(receipt_store_segment(run_id))
124            .join("run-bundle.json")
125    }
126
127    pub fn bundle_path_for_run_id_and_digest(
128        &self,
129        run_id: &str,
130        content_digest: &ContentDigest,
131    ) -> PathBuf {
132        self.config
133            .bundles_path
134            .join(receipt_store_segment(run_id))
135            .join(content_digest.hex())
136            .join("run-bundle.json")
137    }
138
139    pub fn write_bundle_value(
140        &self,
141        bundle: &Value,
142    ) -> Result<RunBundleStoreRecord, RunBundleStoreError> {
143        let schema = bundle
144            .get("schema")
145            .and_then(Value::as_str)
146            .unwrap_or_default();
147        if schema != "AiDENsRunBundleV3" {
148            return Err(RunBundleStoreError::UnsupportedSchema(schema.into()));
149        }
150        let run_id = bundle
151            .get("run_id")
152            .and_then(Value::as_str)
153            .ok_or(RunBundleStoreError::MissingRunId)?;
154        let content_digest = ContentDigest::compute_json(bundle)
155            .map_err(|source| RunBundleStoreError::Digest { source })?;
156        let bundle_path = self.bundle_path_for_run_id_and_digest(run_id, &content_digest);
157        if bundle_path.exists() {
158            return Err(RunBundleStoreError::AlreadyExists(
159                bundle_path.display().to_string(),
160            ));
161        }
162        if let Some(parent) = bundle_path.parent() {
163            std::fs::create_dir_all(parent).map_err(|source| RunBundleStoreError::Io {
164                path: parent.to_path_buf(),
165                source,
166            })?;
167        }
168        let body =
169            serde_json::to_string_pretty(bundle).map_err(|source| RunBundleStoreError::Json {
170                path: bundle_path.clone(),
171                source,
172            })?;
173        write_atomic_file(&bundle_path, &(body + "\n")).map_err(|source| {
174            RunBundleStoreError::Io {
175                path: bundle_path.clone(),
176                source,
177            }
178        })?;
179        let record = RunBundleStoreRecord {
180            artifact_kind: "local_operator_run_bundle_store_record".into(),
181            ownership:
182                "AiDENs-local operator evidence; canonical receipt and trace semantics remain in owner crates.".into(),
183            support_tier: bundle
184                .pointer("/support/support_tier")
185                .and_then(Value::as_str)
186                .unwrap_or("partial")
187                .into(),
188            semantic_status: if bundle
189                .pointer("/failure/degraded")
190                .and_then(Value::as_bool)
191                .unwrap_or(false)
192            {
193                "degraded_exact_check".into()
194            } else {
195                "exact_check".into()
196            },
197            run_id: run_id.into(),
198            bundle_schema: schema.into(),
199            recorded_at: Utc::now().to_rfc3339(),
200            bundle_path: bundle_path.clone(),
201            content_digest,
202            canonical_event_log_path: self.config.root_path.join("canonical-receipts.ndjson"),
203            known_limits: vec![
204                "Stores AiDENsRunBundleV3 operator evidence only; it is not a canonical memory or verification truth store.".into(),
205            ],
206        };
207        if let Err(error) = self.append_record(&record) {
208            let _ = std::fs::remove_file(&bundle_path);
209            return Err(error);
210        }
211        Ok(record)
212    }
213
214    pub fn write_bundle<T: Serialize>(
215        &self,
216        bundle: &T,
217    ) -> Result<RunBundleStoreRecord, RunBundleStoreError> {
218        let value = serde_json::to_value(bundle).map_err(|source| RunBundleStoreError::Json {
219            path: self.config.index_path.clone(),
220            source,
221        })?;
222        self.write_bundle_value(&value)
223    }
224
225    pub fn list_records(&self) -> Result<Vec<RunBundleStoreRecord>, RunBundleStoreError> {
226        read_run_bundle_records(&self.config.index_path)
227    }
228
229    pub fn inspect(&self, run_id: &str) -> Result<RunBundleStoreInspection, RunBundleStoreError> {
230        let record = self
231            .list_records()?
232            .into_iter()
233            .rev()
234            .find(|record| record.run_id == run_id)
235            .ok_or_else(|| RunBundleStoreError::NotFound(run_id.into()))?;
236        let bundle_text = std::fs::read_to_string(&record.bundle_path).map_err(|source| {
237            RunBundleStoreError::Io {
238                path: record.bundle_path.clone(),
239                source,
240            }
241        })?;
242        let bundle: Value =
243            serde_json::from_str(&bundle_text).map_err(|source| RunBundleStoreError::Json {
244                path: record.bundle_path.clone(),
245                source,
246            })?;
247        let digest_verified = ContentDigest::compute_json(&bundle)
248            .map(|digest| digest == record.content_digest)
249            .unwrap_or(false);
250        Ok(RunBundleStoreInspection {
251            record,
252            bundle,
253            digest_verified,
254        })
255    }
256
257    pub fn single_bundle_path(&self) -> Result<PathBuf, RunBundleStoreError> {
258        let records = self.list_records()?;
259        let mut paths = records
260            .into_iter()
261            .map(|record| record.bundle_path)
262            .collect::<Vec<_>>();
263        paths.sort();
264        paths.dedup();
265        match paths.len() {
266            0 => Err(RunBundleStoreError::NotFound(
267                "run-bundles/index.ndjson".into(),
268            )),
269            1 => Ok(paths.remove(0)),
270            _ => Err(RunBundleStoreError::AmbiguousBundle),
271        }
272    }
273
274    fn append_record(&self, record: &RunBundleStoreRecord) -> Result<(), RunBundleStoreError> {
275        let _lock = acquire_exclusive_lock(&self.config.index_path).map_err(|source| {
276            RunBundleStoreError::Io {
277                path: lock_path_for(&self.config.index_path),
278                source,
279            }
280        })?;
281        let mut file = OpenOptions::new()
282            .create(true)
283            .append(true)
284            .open(&self.config.index_path)
285            .map_err(|source| RunBundleStoreError::Io {
286                path: self.config.index_path.clone(),
287                source,
288            })?;
289        serde_json::to_writer(&mut file, record).map_err(|source| RunBundleStoreError::Json {
290            path: self.config.index_path.clone(),
291            source,
292        })?;
293        file.write_all(b"\n")
294            .map_err(|source| RunBundleStoreError::Io {
295                path: self.config.index_path.clone(),
296                source,
297            })?;
298        file.flush().map_err(|source| RunBundleStoreError::Io {
299            path: self.config.index_path.clone(),
300            source,
301        })?;
302        file.sync_all().map_err(|source| RunBundleStoreError::Io {
303            path: self.config.index_path.clone(),
304            source,
305        })?;
306        Ok(())
307    }
308}
309
310pub fn forge_tool_receipt_from_runtime(
311    receipt: &CanonicalRuntimeToolReceipt,
312    raw_payload: Value,
313) -> CanonicalForgeToolReceiptV2 {
314    receipt.to_forge_tool_receipt_v2(raw_payload)
315}
316
317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
318pub struct CanonicalEventLogConfig {
319    pub root_path: PathBuf,
320    pub records_path: PathBuf,
321    #[serde(default)]
322    pub append_only: bool,
323}
324
325impl CanonicalEventLogConfig {
326    pub fn for_root(root_path: impl Into<PathBuf>) -> Self {
327        let root_path = root_path.into();
328        Self {
329            records_path: root_path.join("canonical-receipts.ndjson"),
330            root_path,
331            append_only: true,
332        }
333    }
334}
335
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337pub struct CanonicalEventLogEntry {
338    #[serde(default)]
339    pub sequence_number: u64,
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub previous_record_digest: Option<ContentDigest>,
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub record_digest: Option<ContentDigest>,
344    pub owner_crate: String,
345    pub schema_name: String,
346    pub receipt_id: String,
347    pub recorded_at: String,
348    pub content_digest: ContentDigest,
349    pub body: Value,
350}
351
352impl CanonicalEventLogEntry {
353    pub fn new(
354        owner_crate: impl Into<String>,
355        schema_name: impl Into<String>,
356        receipt_id: impl Into<String>,
357        body: Value,
358    ) -> Result<Self, CanonicalEventLogError> {
359        Self::new_with_chain(owner_crate, schema_name, receipt_id, body, 0, None)
360    }
361
362    pub fn new_with_chain(
363        owner_crate: impl Into<String>,
364        schema_name: impl Into<String>,
365        receipt_id: impl Into<String>,
366        body: Value,
367        sequence_number: u64,
368        previous_record_digest: Option<ContentDigest>,
369    ) -> Result<Self, CanonicalEventLogError> {
370        let content_digest = ContentDigest::compute_json(&body)
371            .map_err(|source| CanonicalEventLogError::Digest { source })?;
372        let mut entry = Self {
373            sequence_number,
374            previous_record_digest,
375            record_digest: None,
376            owner_crate: owner_crate.into(),
377            schema_name: schema_name.into(),
378            receipt_id: receipt_id.into(),
379            recorded_at: Utc::now().to_rfc3339(),
380            content_digest,
381            body,
382        };
383        entry.record_digest = Some(entry.compute_record_digest()?);
384        Ok(entry)
385    }
386
387    pub fn verify_digest(&self) -> bool {
388        ContentDigest::compute_json(&self.body)
389            .map(|digest| digest == self.content_digest)
390            .unwrap_or(false)
391    }
392
393    pub fn compute_record_digest(&self) -> Result<ContentDigest, CanonicalEventLogError> {
394        let payload = serde_json::json!({
395            "sequence_number": self.sequence_number,
396            "previous_record_digest": self.previous_record_digest,
397            "owner_crate": self.owner_crate,
398            "schema_name": self.schema_name,
399            "receipt_id": self.receipt_id,
400            "recorded_at": self.recorded_at,
401            "content_digest": self.content_digest,
402            "body": self.body,
403        });
404        ContentDigest::compute_json(&payload)
405            .map_err(|source| CanonicalEventLogError::Digest { source })
406    }
407
408    pub fn verify_record_digest(&self) -> bool {
409        self.record_digest.as_ref().is_some_and(|expected| {
410            self.compute_record_digest()
411                .is_ok_and(|actual| actual == *expected)
412        })
413    }
414}
415
416#[derive(Debug, Error)]
417pub enum CanonicalEventLogError {
418    #[error("canonical receipt log io error at {path}: {source}")]
419    Io {
420        path: PathBuf,
421        #[source]
422        source: std::io::Error,
423    },
424    #[error("canonical receipt log json error at {path}: {source}")]
425    Json {
426        path: PathBuf,
427        #[source]
428        source: serde_json::Error,
429    },
430    #[error("canonical receipt digest error: {source}")]
431    Digest {
432        #[source]
433        source: stack_ids::DigestError,
434    },
435    #[error("canonical receipt record not found: {0}")]
436    NotFound(String),
437    #[error("canonical receipt log chain verification failed at sequence {0}")]
438    ChainVerificationFailed(u64),
439    #[error("canonical receipt log duplicate receipt id: {0}")]
440    DuplicateReceiptId(String),
441}
442
443#[derive(Debug, Clone)]
444pub struct CanonicalEventLog {
445    config: CanonicalEventLogConfig,
446}
447
448impl CanonicalEventLog {
449    pub fn open(config: CanonicalEventLogConfig) -> Result<Self, CanonicalEventLogError> {
450        std::fs::create_dir_all(&config.root_path).map_err(|source| {
451            CanonicalEventLogError::Io {
452                path: config.root_path.clone(),
453                source,
454            }
455        })?;
456        ensure_file(&config.records_path)?;
457        Ok(Self { config })
458    }
459
460    pub fn config(&self) -> &CanonicalEventLogConfig {
461        &self.config
462    }
463
464    pub fn append_runtime_tool_receipt(
465        &self,
466        receipt: &CanonicalRuntimeToolReceipt,
467    ) -> Result<CanonicalEventLogEntry, CanonicalEventLogError> {
468        self.append_json(
469            "llm-tool-runtime",
470            "tool-receipt",
471            receipt.receipt_id.clone(),
472            serde_json::to_value(receipt).map_err(|source| CanonicalEventLogError::Json {
473                path: self.config.records_path.clone(),
474                source,
475            })?,
476        )
477    }
478
479    pub fn append_forge_tool_receipt(
480        &self,
481        receipt: &CanonicalForgeToolReceiptV2,
482    ) -> Result<CanonicalEventLogEntry, CanonicalEventLogError> {
483        let event_log_receipt_id = format!(
484            "semantic-memory-forge:forge-tool-receipt-v2:{}",
485            receipt.receipt_id
486        );
487        self.append_json(
488            "semantic-memory-forge",
489            "forge-tool-receipt-v2",
490            event_log_receipt_id,
491            serde_json::to_value(receipt).map_err(|source| CanonicalEventLogError::Json {
492                path: self.config.records_path.clone(),
493                source,
494            })?,
495        )
496    }
497
498    pub fn append_control_receipt(
499        &self,
500        receipt: &CanonicalControlReceipt,
501    ) -> Result<CanonicalEventLogEntry, CanonicalEventLogError> {
502        self.append_json(
503            "verification-control",
504            "control-receipt",
505            receipt.receipt_id.to_string(),
506            serde_json::to_value(receipt).map_err(|source| CanonicalEventLogError::Json {
507                path: self.config.records_path.clone(),
508                source,
509            })?,
510        )
511    }
512
513    pub fn append_orchestration_report(
514        &self,
515        schema_name: impl Into<String>,
516        report_id: impl Into<String>,
517        body: Value,
518    ) -> Result<CanonicalEventLogEntry, CanonicalEventLogError> {
519        self.append_json("aidens-orchestration", schema_name, report_id, body)
520    }
521
522    pub fn append_json(
523        &self,
524        owner_crate: impl Into<String>,
525        schema_name: impl Into<String>,
526        receipt_id: impl Into<String>,
527        body: Value,
528    ) -> Result<CanonicalEventLogEntry, CanonicalEventLogError> {
529        let owner_crate = owner_crate.into();
530        let schema_name = schema_name.into();
531        let receipt_id = receipt_id.into();
532        let _lock = acquire_exclusive_lock(&self.config.records_path).map_err(|source| {
533            CanonicalEventLogError::Io {
534                path: lock_path_for(&self.config.records_path),
535                source,
536            }
537        })?;
538        let records = read_records(&self.config.records_path)?;
539        if records.iter().any(|record| record.receipt_id == receipt_id) {
540            return Err(CanonicalEventLogError::DuplicateReceiptId(receipt_id));
541        }
542        let sequence_number = records
543            .last()
544            .map(|record| record.sequence_number + 1)
545            .unwrap_or(0);
546        let previous_record_digest = records
547            .last()
548            .and_then(|record| record.record_digest.clone());
549        let record = CanonicalEventLogEntry::new_with_chain(
550            owner_crate,
551            schema_name,
552            receipt_id,
553            body,
554            sequence_number,
555            previous_record_digest,
556        )?;
557        self.append_record_locked(&record)?;
558        Ok(record)
559    }
560
561    pub fn append_record(
562        &self,
563        record: &CanonicalEventLogEntry,
564    ) -> Result<(), CanonicalEventLogError> {
565        let _lock = acquire_exclusive_lock(&self.config.records_path).map_err(|source| {
566            CanonicalEventLogError::Io {
567                path: lock_path_for(&self.config.records_path),
568                source,
569            }
570        })?;
571        let records = read_records(&self.config.records_path)?;
572        if records
573            .iter()
574            .any(|existing| existing.receipt_id == record.receipt_id)
575        {
576            return Err(CanonicalEventLogError::DuplicateReceiptId(
577                record.receipt_id.clone(),
578            ));
579        }
580        let expected_sequence = records
581            .last()
582            .map(|existing| existing.sequence_number + 1)
583            .unwrap_or(0);
584        let expected_previous = records
585            .last()
586            .and_then(|existing| existing.record_digest.clone());
587        if record.sequence_number != expected_sequence
588            || record.previous_record_digest != expected_previous
589            || !record.verify_digest()
590            || !record.verify_record_digest()
591        {
592            return Err(CanonicalEventLogError::ChainVerificationFailed(
593                record.sequence_number,
594            ));
595        }
596        self.append_record_locked(record)
597    }
598
599    fn append_record_locked(
600        &self,
601        record: &CanonicalEventLogEntry,
602    ) -> Result<(), CanonicalEventLogError> {
603        let mut file = OpenOptions::new()
604            .create(true)
605            .append(true)
606            .open(&self.config.records_path)
607            .map_err(|source| CanonicalEventLogError::Io {
608                path: self.config.records_path.clone(),
609                source,
610            })?;
611        serde_json::to_writer(&mut file, record).map_err(|source| {
612            CanonicalEventLogError::Json {
613                path: self.config.records_path.clone(),
614                source,
615            }
616        })?;
617        file.write_all(b"\n")
618            .map_err(|source| CanonicalEventLogError::Io {
619                path: self.config.records_path.clone(),
620                source,
621            })?;
622        file.flush().map_err(|source| CanonicalEventLogError::Io {
623            path: self.config.records_path.clone(),
624            source,
625        })?;
626        file.sync_all()
627            .map_err(|source| CanonicalEventLogError::Io {
628                path: self.config.records_path.clone(),
629                source,
630            })?;
631        Ok(())
632    }
633
634    pub fn list_records(&self) -> Result<Vec<CanonicalEventLogEntry>, CanonicalEventLogError> {
635        read_records(&self.config.records_path)
636    }
637
638    pub fn inspect(
639        &self,
640        receipt_id: &str,
641    ) -> Result<CanonicalEventLogEntry, CanonicalEventLogError> {
642        self.list_records()?
643            .into_iter()
644            .find(|record| record.receipt_id == receipt_id)
645            .ok_or_else(|| CanonicalEventLogError::NotFound(receipt_id.into()))
646    }
647
648    pub fn verify_digest(&self, receipt_id: &str) -> Result<bool, CanonicalEventLogError> {
649        Ok(self.inspect(receipt_id)?.verify_digest())
650    }
651
652    pub fn verify_chain(&self) -> Result<bool, CanonicalEventLogError> {
653        let records = self.list_records()?;
654        let mut previous_digest: Option<ContentDigest> = None;
655        for (index, record) in records.iter().enumerate() {
656            if record.sequence_number != index as u64
657                || record.previous_record_digest != previous_digest
658                || !record.verify_digest()
659                || !record.verify_record_digest()
660            {
661                return Ok(false);
662            }
663            previous_digest = record.record_digest.clone();
664        }
665        Ok(true)
666    }
667}
668
669#[async_trait]
670impl ToolReceiptSink for CanonicalEventLog {
671    async fn persist(&self, receipt: &CanonicalRuntimeToolReceipt) -> Result<(), ToolError> {
672        self.append_runtime_tool_receipt(receipt)
673            .map(|_| ())
674            .map_err(|error| {
675                ToolError::new(
676                    ToolErrorClass::ReceiptPersistence,
677                    format!("canonical receipt sink failed: {error}"),
678                )
679            })
680    }
681}
682
683fn ensure_file(path: &Path) -> Result<(), CanonicalEventLogError> {
684    if let Some(parent) = path.parent() {
685        std::fs::create_dir_all(parent).map_err(|source| CanonicalEventLogError::Io {
686            path: parent.to_path_buf(),
687            source,
688        })?;
689    }
690    OpenOptions::new()
691        .create(true)
692        .append(true)
693        .open(path)
694        .map_err(|source| CanonicalEventLogError::Io {
695            path: path.to_path_buf(),
696            source,
697        })?;
698    Ok(())
699}
700
701fn ensure_run_bundle_file(path: &Path) -> Result<(), RunBundleStoreError> {
702    if let Some(parent) = path.parent() {
703        std::fs::create_dir_all(parent).map_err(|source| RunBundleStoreError::Io {
704            path: parent.to_path_buf(),
705            source,
706        })?;
707    }
708    OpenOptions::new()
709        .create(true)
710        .append(true)
711        .open(path)
712        .map_err(|source| RunBundleStoreError::Io {
713            path: path.to_path_buf(),
714            source,
715        })?;
716    Ok(())
717}
718
719fn read_run_bundle_records(path: &Path) -> Result<Vec<RunBundleStoreRecord>, RunBundleStoreError> {
720    ensure_run_bundle_file(path)?;
721    let file = File::open(path).map_err(|source| RunBundleStoreError::Io {
722        path: path.to_path_buf(),
723        source,
724    })?;
725    let reader = BufReader::new(file);
726    let mut records = Vec::new();
727    for (index, line) in reader.lines().enumerate() {
728        let line = line.map_err(|source| RunBundleStoreError::Io {
729            path: path.to_path_buf(),
730            source,
731        })?;
732        if line.trim().is_empty() {
733            continue;
734        }
735        match serde_json::from_str(&line) {
736            Ok(record) => records.push(record),
737            Err(source) => {
738                quarantine_corrupt_line(path, index + 1, &line, &source).map_err(|source| {
739                    RunBundleStoreError::Io {
740                        path: quarantine_path_for(path),
741                        source,
742                    }
743                })?;
744            }
745        }
746    }
747    Ok(records)
748}
749
750fn write_atomic_file(path: &Path, body: &str) -> std::io::Result<()> {
751    if let Some(parent) = path.parent() {
752        std::fs::create_dir_all(parent)?;
753    }
754    let tmp_path = path.with_file_name(format!(
755        ".{}.tmp-{}-{}",
756        path.file_name()
757            .and_then(|name| name.to_str())
758            .unwrap_or("receipt"),
759        std::process::id(),
760        Utc::now()
761            .timestamp_nanos_opt()
762            .unwrap_or_else(|| Utc::now().timestamp_micros())
763    ));
764    {
765        let mut file = OpenOptions::new()
766            .create_new(true)
767            .write(true)
768            .open(&tmp_path)?;
769        file.write_all(body.as_bytes())?;
770        file.flush()?;
771        file.sync_all()?;
772    }
773    std::fs::rename(&tmp_path, path)?;
774    if let Some(parent) = path.parent() {
775        sync_directory(parent)?;
776    }
777    Ok(())
778}
779
780fn sync_directory(path: &Path) -> std::io::Result<()> {
781    File::open(path)?.sync_all()
782}
783
784fn acquire_exclusive_lock(path: &Path) -> std::io::Result<ExclusiveFileLock> {
785    let lock_path = lock_path_for(path);
786    if let Some(parent) = lock_path.parent() {
787        std::fs::create_dir_all(parent)?;
788    }
789    let start = Instant::now();
790    loop {
791        match OpenOptions::new()
792            .create_new(true)
793            .write(true)
794            .open(&lock_path)
795        {
796            Ok(mut file) => {
797                writeln!(
798                    file,
799                    "pid={} acquired_at={}",
800                    std::process::id(),
801                    Utc::now().to_rfc3339()
802                )?;
803                file.sync_all()?;
804                return Ok(ExclusiveFileLock { path: lock_path });
805            }
806            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
807                if start.elapsed() >= Duration::from_secs(10) {
808                    return Err(std::io::Error::new(
809                        std::io::ErrorKind::TimedOut,
810                        format!("receipt log lock still held at {}", lock_path.display()),
811                    ));
812                }
813                thread::sleep(Duration::from_millis(10));
814            }
815            Err(error) => return Err(error),
816        }
817    }
818}
819
820fn lock_path_for(path: &Path) -> PathBuf {
821    path.with_file_name(format!(
822        "{}.lock",
823        path.file_name()
824            .and_then(|name| name.to_str())
825            .unwrap_or("receipt-log")
826    ))
827}
828
829struct ExclusiveFileLock {
830    path: PathBuf,
831}
832
833impl Drop for ExclusiveFileLock {
834    fn drop(&mut self) {
835        let _ = std::fs::remove_file(&self.path);
836    }
837}
838
839fn quarantine_path_for(path: &Path) -> PathBuf {
840    let file_name = path
841        .file_name()
842        .and_then(|name| name.to_str())
843        .unwrap_or("receipt-log");
844    path.parent()
845        .unwrap_or_else(|| Path::new("."))
846        .join("quarantine")
847        .join(format!("{file_name}.corrupt.ndjson"))
848}
849
850fn quarantine_corrupt_line(
851    path: &Path,
852    line_number: usize,
853    raw_line: &str,
854    error: &serde_json::Error,
855) -> std::io::Result<()> {
856    let quarantine_path = quarantine_path_for(path);
857    if let Some(parent) = quarantine_path.parent() {
858        std::fs::create_dir_all(parent)?;
859    }
860    let _lock = acquire_exclusive_lock(&quarantine_path)?;
861    let mut file = OpenOptions::new()
862        .create(true)
863        .append(true)
864        .open(&quarantine_path)?;
865    let record = serde_json::json!({
866        "artifact_kind": "aidens_local_corrupt_receipt_log_line_quarantine",
867        "source_path": path.display().to_string(),
868        "line_number": line_number,
869        "observed_at": Utc::now().to_rfc3339(),
870        "error": error.to_string(),
871        "raw_line": raw_line,
872    });
873    serde_json::to_writer(&mut file, &record)?;
874    file.write_all(b"\n")?;
875    file.flush()?;
876    file.sync_all()
877}
878
879fn read_records(path: &Path) -> Result<Vec<CanonicalEventLogEntry>, CanonicalEventLogError> {
880    ensure_file(path)?;
881    let file = File::open(path).map_err(|source| CanonicalEventLogError::Io {
882        path: path.to_path_buf(),
883        source,
884    })?;
885    let reader = BufReader::new(file);
886    let mut records = Vec::new();
887    for (index, line) in reader.lines().enumerate() {
888        let line = line.map_err(|source| CanonicalEventLogError::Io {
889            path: path.to_path_buf(),
890            source,
891        })?;
892        if line.trim().is_empty() {
893            continue;
894        }
895        match serde_json::from_str(&line) {
896            Ok(record) => records.push(record),
897            Err(source) => {
898                quarantine_corrupt_line(path, index + 1, &line, &source).map_err(|source| {
899                    CanonicalEventLogError::Io {
900                        path: quarantine_path_for(path),
901                        source,
902                    }
903                })?;
904            }
905        }
906    }
907    Ok(records)
908}
909
910fn receipt_store_segment(value: &str) -> String {
911    let mut out = String::new();
912    for ch in value.chars() {
913        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
914            out.push(ch.to_ascii_lowercase());
915        } else {
916            out.push('-');
917        }
918    }
919    let out = out.trim_matches('-').to_string();
920    if out.is_empty() {
921        "run".into()
922    } else {
923        out
924    }
925}
926
927#[cfg(test)]
928mod tests {
929    use super::*;
930
931    #[test]
932    fn canonical_log_persists_owner_labeled_records() {
933        let root = std::env::temp_dir().join(format!(
934            "aidens-canonical-receipts-{}",
935            uuid::Uuid::new_v4()
936        ));
937        let log = CanonicalEventLog::open(CanonicalEventLogConfig::for_root(&root)).unwrap();
938
939        let record = log
940            .append_orchestration_report(
941                "operator-status-report",
942                "report:test",
943                serde_json::json!({"status": "ok"}),
944            )
945            .unwrap();
946
947        assert_eq!(record.owner_crate, "aidens-orchestration");
948        assert_eq!(record.sequence_number, 0);
949        assert!(record.verify_digest());
950        assert!(record.verify_record_digest());
951        assert_eq!(log.inspect("report:test").unwrap(), record);
952        assert!(log.verify_digest("report:test").unwrap());
953        assert!(log.verify_chain().unwrap());
954    }
955
956    #[test]
957    fn p28_canonical_log_digest_chain_detects_tampering() {
958        let root = std::env::temp_dir().join(format!(
959            "aidens-canonical-receipts-chain-{}",
960            uuid::Uuid::new_v4()
961        ));
962        let log = CanonicalEventLog::open(CanonicalEventLogConfig::for_root(&root)).unwrap();
963        let first = log
964            .append_orchestration_report(
965                "operator-status-report",
966                "report:first",
967                serde_json::json!({"status": "first"}),
968            )
969            .unwrap();
970        let second = log
971            .append_orchestration_report(
972                "operator-status-report",
973                "report:second",
974                serde_json::json!({"status": "second"}),
975            )
976            .unwrap();
977
978        assert_eq!(second.sequence_number, 1);
979        assert_eq!(second.previous_record_digest, first.record_digest);
980        assert!(log.verify_chain().unwrap());
981
982        let path = log.config().records_path.clone();
983        let mut lines = std::fs::read_to_string(&path)
984            .unwrap()
985            .lines()
986            .map(str::to_string)
987            .collect::<Vec<_>>();
988        let mut tampered: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
989        tampered["body"]["status"] = serde_json::json!("tampered");
990        lines[0] = serde_json::to_string(&tampered).unwrap();
991        std::fs::write(&path, lines.join("\n") + "\n").unwrap();
992
993        let reopened = CanonicalEventLog::open(CanonicalEventLogConfig::for_root(&root)).unwrap();
994        assert!(!reopened.verify_chain().unwrap());
995        let _ = std::fs::remove_dir_all(root);
996    }
997
998    #[test]
999    fn phase01_concurrent_canonical_appends_keep_single_digest_chain() {
1000        let root = std::env::temp_dir().join(format!(
1001            "aidens-canonical-receipts-concurrent-{}",
1002            uuid::Uuid::new_v4()
1003        ));
1004        let log = CanonicalEventLog::open(CanonicalEventLogConfig::for_root(&root)).unwrap();
1005        let mut handles = Vec::new();
1006
1007        for index in 0..24 {
1008            let log = log.clone();
1009            handles.push(std::thread::spawn(move || {
1010                log.append_orchestration_report(
1011                    "operator-status-report",
1012                    format!("report:concurrent:{index}"),
1013                    serde_json::json!({ "index": index }),
1014                )
1015                .unwrap();
1016            }));
1017        }
1018
1019        for handle in handles {
1020            handle.join().unwrap();
1021        }
1022
1023        let records = log.list_records().unwrap();
1024        assert_eq!(records.len(), 24);
1025        assert!(log.verify_chain().unwrap());
1026        for (index, record) in records.iter().enumerate() {
1027            assert_eq!(record.sequence_number, index as u64);
1028            assert!(record.verify_record_digest());
1029        }
1030        let _ = std::fs::remove_dir_all(root);
1031    }
1032
1033    #[test]
1034    fn phase01_corrupt_trailing_record_is_quarantined_not_history_poisoning() {
1035        let root = std::env::temp_dir().join(format!(
1036            "aidens-canonical-receipts-corrupt-{}",
1037            uuid::Uuid::new_v4()
1038        ));
1039        let log = CanonicalEventLog::open(CanonicalEventLogConfig::for_root(&root)).unwrap();
1040        log.append_orchestration_report(
1041            "operator-status-report",
1042            "report:before-corruption",
1043            serde_json::json!({"status": "ok"}),
1044        )
1045        .unwrap();
1046        {
1047            let mut file = OpenOptions::new()
1048                .append(true)
1049                .open(log.config().records_path.clone())
1050                .unwrap();
1051            file.write_all(b"{not-json}\n").unwrap();
1052            file.sync_all().unwrap();
1053        }
1054
1055        let reopened = CanonicalEventLog::open(CanonicalEventLogConfig::for_root(&root)).unwrap();
1056        let records = reopened.list_records().unwrap();
1057        assert_eq!(records.len(), 1);
1058        assert!(reopened.verify_chain().unwrap());
1059        let quarantine = quarantine_path_for(&reopened.config().records_path);
1060        let quarantine_text = std::fs::read_to_string(quarantine).unwrap();
1061        assert!(quarantine_text.contains("aidens_local_corrupt_receipt_log_line_quarantine"));
1062        assert!(quarantine_text.contains("not-json"));
1063        let appended = reopened
1064            .append_orchestration_report(
1065                "operator-status-report",
1066                "report:after-corruption",
1067                serde_json::json!({"status": "continued"}),
1068            )
1069            .unwrap();
1070        assert_eq!(appended.sequence_number, 1);
1071        assert!(reopened.verify_chain().unwrap());
1072        let _ = std::fs::remove_dir_all(root);
1073    }
1074
1075    #[test]
1076    fn phase01_duplicate_receipt_ids_are_rejected() {
1077        let root = std::env::temp_dir().join(format!(
1078            "aidens-canonical-receipts-duplicate-{}",
1079            uuid::Uuid::new_v4()
1080        ));
1081        let log = CanonicalEventLog::open(CanonicalEventLogConfig::for_root(&root)).unwrap();
1082        log.append_orchestration_report(
1083            "operator-status-report",
1084            "report:duplicate",
1085            serde_json::json!({"status": "first"}),
1086        )
1087        .unwrap();
1088
1089        let err = log
1090            .append_orchestration_report(
1091                "operator-status-report",
1092                "report:duplicate",
1093                serde_json::json!({"status": "second"}),
1094            )
1095            .unwrap_err();
1096        assert!(matches!(
1097            err,
1098            CanonicalEventLogError::DuplicateReceiptId(id) if id == "report:duplicate"
1099        ));
1100        assert!(log.verify_chain().unwrap());
1101        let _ = std::fs::remove_dir_all(root);
1102    }
1103
1104    #[test]
1105    fn run_bundle_store_persists_v3_operator_evidence() {
1106        let root =
1107            std::env::temp_dir().join(format!("aidens-run-bundle-store-{}", uuid::Uuid::new_v4()));
1108        let store = RunBundleStore::open(RunBundleStoreConfig::for_receipt_root(&root)).unwrap();
1109        let bundle = serde_json::json!({
1110            "schema": "AiDENsRunBundleV3",
1111            "run_id": "agent:fixture/run",
1112            "support": {
1113                "support_tier": "supported-local"
1114            },
1115            "failure": {
1116                "degraded": false
1117            }
1118        });
1119
1120        let record = store.write_bundle_value(&bundle).unwrap();
1121        assert_eq!(record.support_tier, "supported-local");
1122        assert_eq!(record.semantic_status, "exact_check");
1123        assert!(record.bundle_path.exists());
1124        assert!(record
1125            .bundle_path
1126            .display()
1127            .to_string()
1128            .contains(record.content_digest.hex()));
1129        assert!(matches!(
1130            store.write_bundle_value(&bundle),
1131            Err(RunBundleStoreError::AlreadyExists(_))
1132        ));
1133
1134        let reopened = RunBundleStore::open(RunBundleStoreConfig::for_receipt_root(&root)).unwrap();
1135        let inspection = reopened.inspect("agent:fixture/run").unwrap();
1136        assert!(inspection.digest_verified);
1137        assert_eq!(inspection.bundle["schema"], "AiDENsRunBundleV3");
1138        assert_eq!(reopened.single_bundle_path().unwrap(), record.bundle_path);
1139        let _ = std::fs::remove_dir_all(root);
1140    }
1141}