Skip to main content

deepstrike_core/runtime/
verifiable.rs

1//! Framework-first 0.2.70 verifiable-runtime contracts.
2//!
3//! The types in this module are deliberately storage-neutral.  They accept evidence bytes that
4//! have already been obtained by a host, SDK, or durable-store adapter; they never open a path,
5//! call a provider, or mutate runtime state.  `deepstrike` is one adapter over this API.
6
7use 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/v2";
13pub const FORK_SCHEMA: &str = "verifiable-fork/v2";
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
38/// Execute one framework operation from a JSON request for language bindings.
39///
40/// The JSON bridge is intentionally a transport adapter, not a second implementation. It decodes
41/// byte arrays into [`EvidenceBundle`], invokes [`VerifiableOperation`], and serializes the typed
42/// result. Hosts still own storage access and may use the typed Rust API directly.
43pub 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/// Evidence planes supplied by a host or SDK adapter.
81///
82/// The bundle owns its bytes so a `VerifiableOperation` can be passed across an FFI boundary
83/// without borrowing a filesystem reader or a process-local store.  The byte layout remains the
84/// canonical wire layout validated by the runtime chain validator.
85#[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/// Options for a framework verification operation.
119#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
120pub struct VerifyOptions {
121    pub strict: bool,
122    pub require_complete: bool,
123}
124
125/// Options for an offline replay operation.
126#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
127pub struct ReplayOptions {
128    pub strict: bool,
129    pub at_step: Option<u64>,
130}
131
132/// A typed, storage-neutral operation view.
133///
134/// This is the framework entry point.  Adapters construct it from their own evidence source and
135/// use the same methods regardless of whether that source is a file, database, object store, or
136/// an in-memory test fixture.
137#[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/// A verified, read-only fork boundary in framework-native types.
252///
253/// A plan is data returned to a host for a later orchestration decision.  It does not append a
254/// kernel input, mutate a checkpoint, or become a recovery authority.  `manifest()` is the stable
255/// cross-process serialization projection.
256#[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
327fn 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
374fn 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
407fn 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    // Prefix replay uses canonical complete records so the order is deterministic. Preserve the
432    // source-plane fact that a blob could not decode; silently dropping a malformed hop would turn
433    // incomplete evidence into a false green replay.
434    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
479fn prepare_fork<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<ForkPlan, String>
487where
488    J: AsRef<[u8]>,
489    S: AsRef<[u8]>,
490    C: AsRef<[u8]>,
491{
492    let replay = replay_operation(
493        operation_id,
494        journal_blobs,
495        session_streams,
496        checkpoint_blobs,
497        strict,
498        Some(at_step),
499    );
500    if replay.verdict != ReplayVerdict::Pass {
501        return Err(replay
502            .first_divergence
503            .unwrap_or_else(|| "fork boundary is not verifiable".to_string()));
504    }
505    let records = operation_records(operation_id, journal_blobs);
506    let parent = records
507        .iter()
508        .find(|record| record.step_seq().get() == at_step)
509        .ok_or_else(|| format!("operation {operation_id} has no step {at_step}"))?;
510    Ok(ForkPlan {
511        operation_id: operation_id.to_string(),
512        at_step,
513        parent_record_digest: parent.record_digest().to_string(),
514        parent_input_id: parent.input_id().to_string(),
515        source_records: records
516            .iter()
517            .filter(|record| record.step_seq().get() <= at_step)
518            .count(),
519    })
520}
521
522fn c3_verdict(segment: &SegmentReport) -> (ReplayVerdict, Option<String>) {
523    let rule = segment.rules.iter().find(|rule| rule.rule == "C3");
524    match rule.map(|rule| rule.verdict) {
525        Some(super::chain_validator::Verdict::Pass) => (ReplayVerdict::Pass, None),
526        Some(super::chain_validator::Verdict::Fail) => {
527            (ReplayVerdict::Fail, rule.map(|rule| rule.detail.clone()))
528        }
529        _ => (
530            ReplayVerdict::Unavailable,
531            rule.map(|rule| rule.detail.clone()),
532        ),
533    }
534}
535
536fn operation_records<B: AsRef<[u8]>>(operation_id: &str, blobs: &[B]) -> Vec<KernelRecord> {
537    let mut records: Vec<_> = blobs
538        .iter()
539        .filter_map(|blob| KernelRecord::from_record_bytes(blob.as_ref()).ok())
540        .filter(|record| record.operation_id().as_str() == operation_id)
541        .collect();
542    records.sort_by_key(|record| record.step_seq().get());
543    records
544}
545
546fn record_summary(record: KernelRecord) -> RecordSummary {
547    let input_kind = record
548        .normalized_input()
549        .map(|input| input.input.kind().to_string())
550        .unwrap_or_else(|_| "unknown".to_string());
551    RecordSummary {
552        step_seq: record.step_seq().to_string(),
553        input_id: record.input_id().to_string(),
554        input_kind,
555        previous_record_digest: record.previous_record_digest().map(ToString::to_string),
556        record_digest: record.record_digest().to_string(),
557        step_digest: record.step_digest().to_string(),
558    }
559}
560
561#[cfg(test)]
562mod tests {
563    use super::{
564        CheckVerdict, EvidenceBundle, ForkManifest, REPORT_SCHEMA, ReplayOptions, ReplayVerdict,
565        VerifiableOperation, VerifyOptions, inspect_operation, operation_json, replay_operation,
566        verify_operation,
567    };
568
569    fn record_fixture() -> Vec<Vec<u8>> {
570        let fixture: serde_json::Value = serde_json::from_str(include_str!(
571            "../../../../tests/fixtures/kernel-wire/golden_record_chain.json"
572        ))
573        .unwrap();
574        fixture["links"]
575            .as_array()
576            .unwrap()
577            .iter()
578            .map(|link| serde_json::to_vec(&link["record"]).unwrap())
579            .collect()
580    }
581
582    #[test]
583    fn report_schema_is_frozen_for_the_minor() {
584        assert_eq!(REPORT_SCHEMA, "verifiable-report/v2");
585    }
586
587    #[test]
588    fn json_bridge_delegates_to_the_framework_operation() {
589        let request = serde_json::json!({
590            "operation_id": "missing",
591            "command": "verify",
592            "evidence": {"journal": []},
593            "require_complete": true,
594        });
595        let result: serde_json::Value =
596            serde_json::from_str(&operation_json(&request.to_string()).unwrap()).unwrap();
597        assert_eq!(result["schema"], REPORT_SCHEMA);
598        assert_eq!(result["operation_id"], "missing");
599        assert_eq!(result["verdict"], "unavailable");
600    }
601
602    #[test]
603    fn a_fork_manifest_round_trips_without_new_authority() {
604        let manifest = ForkManifest {
605            schema: "verifiable-fork/v2".to_string(),
606            operation_id: "op-1".to_string(),
607            at_step: "3".to_string(),
608            parent_record_digest: "sha256:parent".to_string(),
609            parent_input_id: "in-3".to_string(),
610            source_records: 4,
611        };
612        let json = serde_json::to_string(&manifest).unwrap();
613        let decoded: ForkManifest = serde_json::from_str(&json).unwrap();
614        assert_eq!(decoded, manifest);
615        assert_eq!(ReplayVerdict::Pass.as_str(), "pass");
616    }
617
618    #[test]
619    fn inspect_selects_one_operation_and_keeps_the_validator_report() {
620        let records = record_fixture();
621        let report = inspect_operation(
622            "op-record-1",
623            &records,
624            &[] as &[Vec<Vec<u8>>],
625            &[] as &[Vec<u8>],
626            false,
627        );
628        assert_eq!(report.schema, REPORT_SCHEMA);
629        assert_eq!(report.records.len(), 3);
630        assert_eq!(report.validation.segments.len(), 1);
631    }
632
633    #[test]
634    fn framework_operation_owns_evidence_and_delegates_all_views() {
635        let records = record_fixture();
636        let operation = VerifiableOperation::new(
637            "op-record-1",
638            EvidenceBundle::new(records, Vec::new(), Vec::new()),
639        );
640        assert_eq!(operation.operation_id(), "op-record-1");
641        assert_eq!(operation.evidence().journal().len(), 3);
642        assert_eq!(operation.inspect(false).records.len(), 3);
643        assert_eq!(
644            operation
645                .verify(VerifyOptions {
646                    strict: false,
647                    require_complete: false,
648                })
649                .operation_id,
650            "op-record-1"
651        );
652        assert_eq!(
653            operation
654                .replay(ReplayOptions {
655                    strict: false,
656                    at_step: Some(99),
657                })
658                .verdict,
659            ReplayVerdict::Unavailable
660        );
661    }
662
663    #[test]
664    fn verify_missing_operation_is_unavailable() {
665        let records = record_fixture();
666        let report = verify_operation(
667            "missing",
668            &records,
669            &[] as &[Vec<Vec<u8>>],
670            &[] as &[Vec<u8>],
671            false,
672            true,
673        );
674        assert_eq!(report.verdict, CheckVerdict::Unavailable);
675        assert_eq!(report.verdict.exit_code(true), 2);
676    }
677
678    #[test]
679    fn verify_require_complete_rejects_a_journal_only_bundle() {
680        let records = record_fixture();
681        let report = verify_operation(
682            "op-record-1",
683            &records,
684            &[] as &[Vec<Vec<u8>>],
685            &[] as &[Vec<u8>],
686            false,
687            true,
688        );
689        assert_eq!(report.verdict, CheckVerdict::Unavailable);
690        assert_eq!(report.verdict.exit_code(true), 2);
691    }
692
693    #[test]
694    fn replay_rejects_a_missing_boundary_without_treating_it_as_a_divergence() {
695        let records = record_fixture();
696        let report = replay_operation(
697            "op-record-1",
698            &records,
699            &[] as &[Vec<Vec<u8>>],
700            &[] as &[Vec<u8>],
701            false,
702            Some(99),
703        );
704        assert_eq!(report.verdict, ReplayVerdict::Unavailable);
705        assert_eq!(report.compared_steps, 3);
706        assert!(report.first_divergence.unwrap().contains("no step 99"));
707    }
708
709    #[test]
710    fn replay_marks_a_malformed_prefix_as_unavailable() {
711        let mut records = record_fixture();
712        records.push(b"{malformed".to_vec());
713        let report = replay_operation(
714            "op-record-1",
715            &records,
716            &[] as &[Vec<Vec<u8>>],
717            &[] as &[Vec<u8>],
718            false,
719            Some(2),
720        );
721        assert_eq!(report.verdict, ReplayVerdict::Unavailable);
722        assert!(report.first_divergence.unwrap().contains("incomplete"));
723    }
724}