1use serde::{Deserialize, Serialize};
8
9use super::chain_validator::{SegmentReport, ValidationReport, validate_with_checkpoint};
10use super::kernel::wire::record::KernelRecord;
11
12pub const REPORT_SCHEMA: &str = "verifiable-report/v1";
13pub const FORK_SCHEMA: &str = "verifiable-fork/v1";
14
15#[derive(Debug, Default, Deserialize)]
16struct JsonEvidence {
17 #[serde(default)]
18 journal: Vec<Vec<u8>>,
19 #[serde(default)]
20 session_logs: Vec<Vec<Vec<u8>>>,
21 #[serde(default)]
22 checkpoints: Vec<Vec<u8>>,
23}
24
25#[derive(Debug, Deserialize)]
26struct JsonOperationRequest {
27 operation_id: String,
28 command: String,
29 #[serde(default)]
30 evidence: JsonEvidence,
31 #[serde(default)]
32 strict: bool,
33 #[serde(default)]
34 require_complete: bool,
35 at_step: Option<u64>,
36}
37
38pub fn operation_json(request: &str) -> Result<String, String> {
44 let request: JsonOperationRequest = serde_json::from_str(request)
45 .map_err(|error| format!("invalid verifiable request: {error}"))?;
46 let operation = VerifiableOperation::new(
47 request.operation_id,
48 EvidenceBundle::new(
49 request.evidence.journal,
50 request.evidence.session_logs,
51 request.evidence.checkpoints,
52 ),
53 );
54 let value = match request.command.as_str() {
55 "inspect" => serde_json::to_value(operation.inspect(request.strict)),
56 "verify" => serde_json::to_value(operation.verify(VerifyOptions {
57 strict: request.strict,
58 require_complete: request.require_complete,
59 })),
60 "replay" => serde_json::to_value(operation.replay(ReplayOptions {
61 strict: request.strict,
62 at_step: request.at_step,
63 })),
64 "fork" => operation
65 .prepare_fork(
66 request
67 .at_step
68 .ok_or_else(|| "fork requires at_step".to_string())?,
69 request.strict,
70 )
71 .map(|plan| serde_json::to_value(plan.manifest()))
72 .map_err(|error| error.to_string())?,
73 other => return Err(format!("unknown verifiable command: {other}")),
74 }
75 .map_err(|error| format!("could not serialize verifiable result: {error}"))?;
76 serde_json::to_string(&value)
77 .map_err(|error| format!("could not encode verifiable result: {error}"))
78}
79
80#[derive(Debug, Clone, Default, PartialEq, Eq)]
86pub struct EvidenceBundle {
87 journal: Vec<Vec<u8>>,
88 session_logs: Vec<Vec<Vec<u8>>>,
89 checkpoints: Vec<Vec<u8>>,
90}
91
92impl EvidenceBundle {
93 pub fn new(
94 journal: Vec<Vec<u8>>,
95 session_logs: Vec<Vec<Vec<u8>>>,
96 checkpoints: Vec<Vec<u8>>,
97 ) -> Self {
98 Self {
99 journal,
100 session_logs,
101 checkpoints,
102 }
103 }
104
105 pub fn journal(&self) -> &[Vec<u8>] {
106 &self.journal
107 }
108
109 pub fn session_logs(&self) -> &[Vec<Vec<u8>>] {
110 &self.session_logs
111 }
112
113 pub fn checkpoints(&self) -> &[Vec<u8>] {
114 &self.checkpoints
115 }
116}
117
118#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
120pub struct VerifyOptions {
121 pub strict: bool,
122 pub require_complete: bool,
123}
124
125#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
127pub struct ReplayOptions {
128 pub strict: bool,
129 pub at_step: Option<u64>,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct VerifiableOperation {
139 operation_id: String,
140 evidence: EvidenceBundle,
141}
142
143impl VerifiableOperation {
144 pub fn new(operation_id: impl Into<String>, evidence: EvidenceBundle) -> Self {
145 Self {
146 operation_id: operation_id.into(),
147 evidence,
148 }
149 }
150
151 pub fn operation_id(&self) -> &str {
152 &self.operation_id
153 }
154
155 pub fn evidence(&self) -> &EvidenceBundle {
156 &self.evidence
157 }
158
159 pub fn inspect(&self, strict: bool) -> InspectReport {
160 inspect_operation(
161 &self.operation_id,
162 self.evidence.journal(),
163 self.evidence.session_logs(),
164 self.evidence.checkpoints(),
165 strict,
166 )
167 }
168
169 pub fn verify(&self, options: VerifyOptions) -> VerifyReport {
170 verify_operation(
171 &self.operation_id,
172 self.evidence.journal(),
173 self.evidence.session_logs(),
174 self.evidence.checkpoints(),
175 options.strict,
176 options.require_complete,
177 )
178 }
179
180 pub fn replay(&self, options: ReplayOptions) -> ReplayReport {
181 replay_operation(
182 &self.operation_id,
183 self.evidence.journal(),
184 self.evidence.session_logs(),
185 self.evidence.checkpoints(),
186 options.strict,
187 options.at_step,
188 )
189 }
190
191 pub fn prepare_fork(&self, at_step: u64, strict: bool) -> Result<ForkPlan, String> {
192 prepare_fork(
193 &self.operation_id,
194 self.evidence.journal(),
195 self.evidence.session_logs(),
196 self.evidence.checkpoints(),
197 strict,
198 at_step,
199 )
200 }
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
204#[serde(rename_all = "snake_case")]
205pub enum ReplayVerdict {
206 Pass,
207 Fail,
208 Unavailable,
209}
210
211impl ReplayVerdict {
212 pub fn as_str(self) -> &'static str {
213 match self {
214 Self::Pass => "pass",
215 Self::Fail => "fail",
216 Self::Unavailable => "unavailable",
217 }
218 }
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
222#[serde(rename_all = "snake_case")]
223pub enum CheckVerdict {
224 Pass,
225 Degraded,
226 Fail,
227 Unavailable,
228}
229
230impl CheckVerdict {
231 pub fn exit_code(self, require_complete: bool) -> i32 {
232 match self {
233 Self::Fail => 1,
234 Self::Unavailable => 2,
235 Self::Degraded if require_complete => 2,
236 Self::Pass | Self::Degraded => 0,
237 }
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242pub struct ForkManifest {
243 pub schema: String,
244 pub operation_id: String,
245 pub at_step: String,
246 pub parent_record_digest: String,
247 pub parent_input_id: String,
248 pub source_records: usize,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct ForkPlan {
258 pub operation_id: String,
259 pub at_step: u64,
260 pub parent_record_digest: String,
261 pub parent_input_id: String,
262 pub source_records: usize,
263}
264
265impl ForkPlan {
266 pub fn manifest(&self) -> ForkManifest {
267 ForkManifest {
268 schema: FORK_SCHEMA.to_string(),
269 operation_id: self.operation_id.clone(),
270 at_step: self.at_step.to_string(),
271 parent_record_digest: self.parent_record_digest.clone(),
272 parent_input_id: self.parent_input_id.clone(),
273 source_records: self.source_records,
274 }
275 }
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
279pub struct RecordSummary {
280 pub step_seq: String,
281 pub input_id: String,
282 pub input_kind: String,
283 pub previous_record_digest: Option<String>,
284 pub record_digest: String,
285 pub step_digest: String,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
289pub struct EvidenceSummary {
290 pub journal_records: usize,
291 pub session_events: Option<usize>,
292 pub checkpoints: Option<usize>,
293}
294
295#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
296pub struct InspectReport {
297 pub schema: String,
298 pub command: String,
299 pub operation_id: String,
300 pub evidence: EvidenceSummary,
301 pub records: Vec<RecordSummary>,
302 pub validation: ValidationReport,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
306pub struct ReplayReport {
307 pub schema: String,
308 pub command: String,
309 pub operation_id: String,
310 pub at_step: Option<String>,
311 pub verdict: ReplayVerdict,
312 pub compared_steps: usize,
313 pub first_divergence: Option<String>,
314 pub validation: ValidationReport,
315}
316
317#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
318pub struct VerifyReport {
319 pub schema: String,
320 pub command: String,
321 pub operation_id: String,
322 pub require_complete: bool,
323 pub verdict: CheckVerdict,
324 pub validation: ValidationReport,
325}
326
327pub fn verify_operation<J, S, C>(
328 operation_id: &str,
329 journal_blobs: &[J],
330 session_streams: &[Vec<S>],
331 checkpoint_blobs: &[C],
332 strict: bool,
333 require_complete: bool,
334) -> VerifyReport
335where
336 J: AsRef<[u8]>,
337 S: AsRef<[u8]>,
338 C: AsRef<[u8]>,
339{
340 let full_validation =
341 validate_with_checkpoint(journal_blobs, session_streams, checkpoint_blobs, strict);
342 let segment = full_validation
343 .segments
344 .iter()
345 .find(|segment| segment.operation_id == operation_id);
346 let missing_required_plane = require_complete
347 && (full_validation.session_events.is_none() || full_validation.checkpoints.is_none());
348 let verdict = if segment.is_none() || missing_required_plane {
349 CheckVerdict::Unavailable
350 } else if full_validation.has_violations_for(operation_id) {
351 CheckVerdict::Fail
352 } else if full_validation.has_insufficient_evidence() {
353 CheckVerdict::Unavailable
354 } else if segment.is_some_and(|segment| {
355 segment
356 .rules
357 .iter()
358 .any(|rule| rule.verdict == super::chain_validator::Verdict::Degraded)
359 }) {
360 CheckVerdict::Degraded
361 } else {
362 CheckVerdict::Pass
363 };
364 VerifyReport {
365 schema: REPORT_SCHEMA.to_string(),
366 command: "verify".to_string(),
367 operation_id: operation_id.to_string(),
368 require_complete,
369 validation: full_validation.for_operation(operation_id),
370 verdict,
371 }
372}
373
374pub fn inspect_operation<J, S, C>(
375 operation_id: &str,
376 journal_blobs: &[J],
377 session_streams: &[Vec<S>],
378 checkpoint_blobs: &[C],
379 strict: bool,
380) -> InspectReport
381where
382 J: AsRef<[u8]>,
383 S: AsRef<[u8]>,
384 C: AsRef<[u8]>,
385{
386 let validation =
387 validate_with_checkpoint(journal_blobs, session_streams, checkpoint_blobs, strict)
388 .for_operation(operation_id);
389 let records = operation_records(operation_id, journal_blobs)
390 .into_iter()
391 .map(record_summary)
392 .collect();
393 InspectReport {
394 schema: REPORT_SCHEMA.to_string(),
395 command: "inspect".to_string(),
396 operation_id: operation_id.to_string(),
397 evidence: EvidenceSummary {
398 journal_records: journal_blobs.len(),
399 session_events: validation.session_events,
400 checkpoints: validation.checkpoints,
401 },
402 records,
403 validation,
404 }
405}
406
407pub fn replay_operation<J, S, C>(
408 operation_id: &str,
409 journal_blobs: &[J],
410 session_streams: &[Vec<S>],
411 checkpoint_blobs: &[C],
412 strict: bool,
413 at_step: Option<u64>,
414) -> ReplayReport
415where
416 J: AsRef<[u8]>,
417 S: AsRef<[u8]>,
418 C: AsRef<[u8]>,
419{
420 let records = operation_records(operation_id, journal_blobs);
421 let selected: Vec<Vec<u8>> = records
422 .iter()
423 .filter(|record| at_step.is_none_or(|at| record.step_seq().get() <= at))
424 .map(|record| record.record_bytes().into_vec())
425 .collect();
426 let mut validation = if at_step.is_none() {
427 validate_with_checkpoint(journal_blobs, session_streams, checkpoint_blobs, strict)
428 } else {
429 validate_with_checkpoint(&selected, session_streams, checkpoint_blobs, strict)
430 };
431 if at_step.is_some()
435 && journal_blobs
436 .iter()
437 .any(|blob| KernelRecord::from_record_bytes(blob.as_ref()).is_err())
438 {
439 validation.unparseable_records = validation.unparseable_records.saturating_add(1);
440 }
441 let segment = validation
442 .segments
443 .iter()
444 .find(|segment| segment.operation_id == operation_id);
445 let output_validation = validation.for_operation(operation_id);
446 let compared_steps = selected.len();
447 let (verdict, first_divergence) = if validation.has_insufficient_evidence() {
448 (
449 ReplayVerdict::Unavailable,
450 Some("journal evidence is incomplete or unparseable".to_string()),
451 )
452 } else if at_step.is_some_and(|at| !records.iter().any(|record| record.step_seq().get() == at))
453 {
454 (
455 ReplayVerdict::Unavailable,
456 at_step.map(|at| format!("operation {operation_id} has no step {at}")),
457 )
458 } else {
459 match segment {
460 None => (
461 ReplayVerdict::Unavailable,
462 Some("operation has no complete records".to_string()),
463 ),
464 Some(segment) => c3_verdict(segment),
465 }
466 };
467 ReplayReport {
468 schema: REPORT_SCHEMA.to_string(),
469 command: "replay".to_string(),
470 operation_id: operation_id.to_string(),
471 at_step: at_step.map(|step| step.to_string()),
472 verdict,
473 compared_steps,
474 first_divergence,
475 validation: output_validation,
476 }
477}
478
479pub fn fork_operation<J, S, C>(
480 operation_id: &str,
481 journal_blobs: &[J],
482 session_streams: &[Vec<S>],
483 checkpoint_blobs: &[C],
484 strict: bool,
485 at_step: u64,
486) -> Result<ForkManifest, String>
487where
488 J: AsRef<[u8]>,
489 S: AsRef<[u8]>,
490 C: AsRef<[u8]>,
491{
492 Ok(prepare_fork(
493 operation_id,
494 journal_blobs,
495 session_streams,
496 checkpoint_blobs,
497 strict,
498 at_step,
499 )?
500 .manifest())
501}
502
503pub fn prepare_fork<J, S, C>(
504 operation_id: &str,
505 journal_blobs: &[J],
506 session_streams: &[Vec<S>],
507 checkpoint_blobs: &[C],
508 strict: bool,
509 at_step: u64,
510) -> Result<ForkPlan, String>
511where
512 J: AsRef<[u8]>,
513 S: AsRef<[u8]>,
514 C: AsRef<[u8]>,
515{
516 let replay = replay_operation(
517 operation_id,
518 journal_blobs,
519 session_streams,
520 checkpoint_blobs,
521 strict,
522 Some(at_step),
523 );
524 if replay.verdict != ReplayVerdict::Pass {
525 return Err(replay
526 .first_divergence
527 .unwrap_or_else(|| "fork boundary is not verifiable".to_string()));
528 }
529 let records = operation_records(operation_id, journal_blobs);
530 let parent = records
531 .iter()
532 .find(|record| record.step_seq().get() == at_step)
533 .ok_or_else(|| format!("operation {operation_id} has no step {at_step}"))?;
534 Ok(ForkPlan {
535 operation_id: operation_id.to_string(),
536 at_step,
537 parent_record_digest: parent.record_digest().to_string(),
538 parent_input_id: parent.input_id().to_string(),
539 source_records: records
540 .iter()
541 .filter(|record| record.step_seq().get() <= at_step)
542 .count(),
543 })
544}
545
546fn c3_verdict(segment: &SegmentReport) -> (ReplayVerdict, Option<String>) {
547 let rule = segment.rules.iter().find(|rule| rule.rule == "C3");
548 match rule.map(|rule| rule.verdict) {
549 Some(super::chain_validator::Verdict::Pass) => (ReplayVerdict::Pass, None),
550 Some(super::chain_validator::Verdict::Fail) => {
551 (ReplayVerdict::Fail, rule.map(|rule| rule.detail.clone()))
552 }
553 _ => (
554 ReplayVerdict::Unavailable,
555 rule.map(|rule| rule.detail.clone()),
556 ),
557 }
558}
559
560fn operation_records<B: AsRef<[u8]>>(operation_id: &str, blobs: &[B]) -> Vec<KernelRecord> {
561 let mut records: Vec<_> = blobs
562 .iter()
563 .filter_map(|blob| KernelRecord::from_record_bytes(blob.as_ref()).ok())
564 .filter(|record| record.operation_id().as_str() == operation_id)
565 .collect();
566 records.sort_by_key(|record| record.step_seq().get());
567 records
568}
569
570fn record_summary(record: KernelRecord) -> RecordSummary {
571 let input_kind = record
572 .normalized_input()
573 .map(|input| input.input.kind().to_string())
574 .unwrap_or_else(|_| "unknown".to_string());
575 RecordSummary {
576 step_seq: record.step_seq().to_string(),
577 input_id: record.input_id().to_string(),
578 input_kind,
579 previous_record_digest: record.previous_record_digest().map(ToString::to_string),
580 record_digest: record.record_digest().to_string(),
581 step_digest: record.step_digest().to_string(),
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::{
588 CheckVerdict, EvidenceBundle, ForkManifest, REPORT_SCHEMA, ReplayOptions, ReplayVerdict,
589 VerifiableOperation, VerifyOptions, inspect_operation, operation_json, replay_operation,
590 verify_operation,
591 };
592
593 fn record_fixture() -> Vec<Vec<u8>> {
594 let fixture: serde_json::Value = serde_json::from_str(include_str!(
595 "../../../../tests/fixtures/kernel-wire/golden_record_chain.json"
596 ))
597 .unwrap();
598 fixture["links"]
599 .as_array()
600 .unwrap()
601 .iter()
602 .map(|link| serde_json::to_vec(&link["record"]).unwrap())
603 .collect()
604 }
605
606 #[test]
607 fn report_schema_is_frozen_for_the_minor() {
608 assert_eq!(REPORT_SCHEMA, "verifiable-report/v1");
609 }
610
611 #[test]
612 fn json_bridge_delegates_to_the_framework_operation() {
613 let request = serde_json::json!({
614 "operation_id": "missing",
615 "command": "verify",
616 "evidence": {"journal": []},
617 "require_complete": true,
618 });
619 let result: serde_json::Value =
620 serde_json::from_str(&operation_json(&request.to_string()).unwrap()).unwrap();
621 assert_eq!(result["schema"], REPORT_SCHEMA);
622 assert_eq!(result["operation_id"], "missing");
623 assert_eq!(result["verdict"], "unavailable");
624 }
625
626 #[test]
627 fn a_fork_manifest_round_trips_without_new_authority() {
628 let manifest = ForkManifest {
629 schema: "verifiable-fork/v1".to_string(),
630 operation_id: "op-1".to_string(),
631 at_step: "3".to_string(),
632 parent_record_digest: "sha256:parent".to_string(),
633 parent_input_id: "in-3".to_string(),
634 source_records: 4,
635 };
636 let json = serde_json::to_string(&manifest).unwrap();
637 let decoded: ForkManifest = serde_json::from_str(&json).unwrap();
638 assert_eq!(decoded, manifest);
639 assert_eq!(ReplayVerdict::Pass.as_str(), "pass");
640 }
641
642 #[test]
643 fn inspect_selects_one_operation_and_keeps_the_validator_report() {
644 let records = record_fixture();
645 let report = inspect_operation(
646 "op-record-1",
647 &records,
648 &[] as &[Vec<Vec<u8>>],
649 &[] as &[Vec<u8>],
650 false,
651 );
652 assert_eq!(report.schema, REPORT_SCHEMA);
653 assert_eq!(report.records.len(), 3);
654 assert_eq!(report.validation.segments.len(), 1);
655 }
656
657 #[test]
658 fn framework_operation_owns_evidence_and_delegates_all_views() {
659 let records = record_fixture();
660 let operation = VerifiableOperation::new(
661 "op-record-1",
662 EvidenceBundle::new(records, Vec::new(), Vec::new()),
663 );
664 assert_eq!(operation.operation_id(), "op-record-1");
665 assert_eq!(operation.evidence().journal().len(), 3);
666 assert_eq!(operation.inspect(false).records.len(), 3);
667 assert_eq!(
668 operation
669 .verify(VerifyOptions {
670 strict: false,
671 require_complete: false,
672 })
673 .operation_id,
674 "op-record-1"
675 );
676 assert_eq!(
677 operation
678 .replay(ReplayOptions {
679 strict: false,
680 at_step: Some(99),
681 })
682 .verdict,
683 ReplayVerdict::Unavailable
684 );
685 }
686
687 #[test]
688 fn verify_missing_operation_is_unavailable() {
689 let records = record_fixture();
690 let report = verify_operation(
691 "missing",
692 &records,
693 &[] as &[Vec<Vec<u8>>],
694 &[] as &[Vec<u8>],
695 false,
696 true,
697 );
698 assert_eq!(report.verdict, CheckVerdict::Unavailable);
699 assert_eq!(report.verdict.exit_code(true), 2);
700 }
701
702 #[test]
703 fn verify_require_complete_rejects_a_journal_only_bundle() {
704 let records = record_fixture();
705 let report = verify_operation(
706 "op-record-1",
707 &records,
708 &[] as &[Vec<Vec<u8>>],
709 &[] as &[Vec<u8>],
710 false,
711 true,
712 );
713 assert_eq!(report.verdict, CheckVerdict::Unavailable);
714 assert_eq!(report.verdict.exit_code(true), 2);
715 }
716
717 #[test]
718 fn replay_rejects_a_missing_boundary_without_treating_it_as_a_divergence() {
719 let records = record_fixture();
720 let report = replay_operation(
721 "op-record-1",
722 &records,
723 &[] as &[Vec<Vec<u8>>],
724 &[] as &[Vec<u8>],
725 false,
726 Some(99),
727 );
728 assert_eq!(report.verdict, ReplayVerdict::Unavailable);
729 assert_eq!(report.compared_steps, 3);
730 assert!(report.first_divergence.unwrap().contains("no step 99"));
731 }
732
733 #[test]
734 fn replay_marks_a_malformed_prefix_as_unavailable() {
735 let mut records = record_fixture();
736 records.push(b"{malformed".to_vec());
737 let report = replay_operation(
738 "op-record-1",
739 &records,
740 &[] as &[Vec<Vec<u8>>],
741 &[] as &[Vec<u8>],
742 false,
743 Some(2),
744 );
745 assert_eq!(report.verdict, ReplayVerdict::Unavailable);
746 assert!(report.first_divergence.unwrap().contains("incomplete"));
747 }
748}