Skip to main content

perspt_agent/toolloop/
envelope.rs

1//! Typed decoding of `tool_loop` ledger payloads (PSP-10 Gate AD).
2//!
3//! One fact, one layer, one decoder per version: rows without
4//! `schema_version` use the strict legacy decoder, which refuses any
5//! post-PSP-10 variant appearing unversioned; version 1 rows decode as
6//! [`LoopEventEnvelopeV1`]; unknown versions fail authoritative replay and
7//! resume closed (a forensic display may still show raw bytes).
8
9use anyhow::Result;
10use perspt_sdk::ledger::{tool_loop_body, ToolLoopBody};
11
12use super::contract::{LoopEvent, LoopEventEnvelopeV1};
13
14/// One decoded runtime event with its envelope version.
15#[derive(Debug, Clone)]
16pub struct DecodedLoopEvent {
17    /// 0 marks a legacy (pre-PSP-10) row.
18    pub version: u16,
19    pub event: LoopEvent,
20}
21
22/// Whether an event variant existed before the PSP-10 envelope. The match
23/// is exhaustive, so adding a variant forces this classification.
24pub(crate) fn is_legacy_variant(event: &LoopEvent) -> bool {
25    match event {
26        LoopEvent::ConversationSeeded { .. }
27        | LoopEvent::ConversationDelta { .. }
28        | LoopEvent::TurnObserved { .. }
29        | LoopEvent::ToolCallObserved { .. }
30        | LoopEvent::ProposalObserved { .. }
31        | LoopEvent::ProposalChecked { .. }
32        | LoopEvent::EffectApplied { .. }
33        | LoopEvent::ToolBatchConflict { .. }
34        | LoopEvent::EffectDenied { .. }
35        | LoopEvent::CandidateMeasured { .. }
36        | LoopEvent::GateDecisionRecorded { .. }
37        | LoopEvent::DecisionBoundRefused { .. }
38        | LoopEvent::CandidateRestored { .. }
39        | LoopEvent::EffectBoundaryMeasured { .. }
40        | LoopEvent::ContextCheckpointCreated { .. }
41        | LoopEvent::DurableCandidateCheckpoint { .. }
42        | LoopEvent::RecoveryControlGranted { .. }
43        | LoopEvent::RouteFailover { .. }
44        | LoopEvent::RecoveryContained { .. } => true,
45        LoopEvent::SearchOpened { .. }
46        | LoopEvent::SearchUsageSnapshot { .. }
47        | LoopEvent::BranchForked { .. }
48        | LoopEvent::BranchStrategySelected { .. }
49        | LoopEvent::BranchObservation { .. }
50        | LoopEvent::BranchCandidateMeasured { .. }
51        | LoopEvent::PartialCheckpointed { .. }
52        | LoopEvent::FrontierEpochStarted { .. }
53        | LoopEvent::FrontierEntryServed { .. }
54        | LoopEvent::BranchIneligible { .. }
55        | LoopEvent::BranchNotSelected { .. }
56        | LoopEvent::BranchAbandoned { .. }
57        | LoopEvent::BranchSelected { .. }
58        | LoopEvent::BranchCommitted { .. }
59        | LoopEvent::NoGoodRecorded { .. }
60        | LoopEvent::SearchClosed { .. }
61        | LoopEvent::ContextWorkingSet { .. }
62        | LoopEvent::ContextPagesSelected { .. }
63        | LoopEvent::ContextMiss { .. }
64        | LoopEvent::ContextPageRecalled { .. }
65        | LoopEvent::ContextInfeasible { .. }
66        | LoopEvent::PromptProgramCompiled { .. }
67        | LoopEvent::PromptProgramInvoked { .. }
68        | LoopEvent::ContextCompacted { .. } => false,
69    }
70}
71
72/// Decode one `tool_loop` payload for authoritative consumption (replay,
73/// resume, refold). Fails closed on unknown versions and on a legacy row
74/// carrying a post-PSP-10 variant.
75pub fn decode_tool_loop(payload: &serde_json::Value) -> Result<DecodedLoopEvent> {
76    match tool_loop_body(payload).map_err(|e| anyhow::anyhow!("{e}"))? {
77        ToolLoopBody::Legacy(body) => {
78            let event: LoopEvent = serde_json::from_value(body.clone())?;
79            anyhow::ensure!(
80                is_legacy_variant(&event),
81                "a PSP-10 event appeared in an unversioned legacy row; refusing"
82            );
83            Ok(DecodedLoopEvent { version: 0, event })
84        }
85        ToolLoopBody::V1(_) => {
86            let envelope: LoopEventEnvelopeV1 = serde_json::from_value(payload.clone())?;
87            anyhow::ensure!(
88                envelope.schema_version == 1,
89                "envelope version {} is not 1",
90                envelope.schema_version
91            );
92            Ok(DecodedLoopEvent {
93                version: 1,
94                event: envelope.body,
95            })
96        }
97    }
98}