1use 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}