Skip to main content

lifeloop/
callback_contract.rs

1//! Callback request/response and dispatch envelope wire shapes.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6    FailureClass, FrameContext, IntegrationMode, LifecycleEventKind, PayloadEnvelope, PayloadRef,
7    ReceiptStatus, RetryClass, SCHEMA_VERSION, ValidationError, Warning, require_non_empty,
8};
9
10// ============================================================================
11// Callback request and response envelopes
12// ============================================================================
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct CallbackRequest {
17    pub schema_version: String,
18    pub event: LifecycleEventKind,
19    pub event_id: String,
20    pub adapter_id: String,
21    pub adapter_version: String,
22    pub integration_mode: IntegrationMode,
23    pub invocation_id: String,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub harness_session_id: Option<String>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub harness_run_id: Option<String>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub harness_task_id: Option<String>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub frame_context: Option<FrameContext>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub capability_snapshot_ref: Option<String>,
34    #[serde(default, skip_serializing_if = "Vec::is_empty")]
35    pub payload_refs: Vec<PayloadRef>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub sequence: Option<u64>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub idempotency_key: Option<String>,
40    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
41    pub metadata: serde_json::Map<String, serde_json::Value>,
42}
43
44impl CallbackRequest {
45    pub fn validate(&self) -> Result<(), ValidationError> {
46        if self.schema_version != SCHEMA_VERSION {
47            return Err(ValidationError::SchemaVersionMismatch {
48                expected: SCHEMA_VERSION.to_string(),
49                found: self.schema_version.clone(),
50            });
51        }
52        require_non_empty(&self.event_id, "request.event_id")?;
53        require_non_empty(&self.adapter_id, "request.adapter_id")?;
54        require_non_empty(&self.adapter_version, "request.adapter_version")?;
55        require_non_empty(&self.invocation_id, "request.invocation_id")?;
56        if let Some(s) = &self.harness_session_id {
57            require_non_empty(s, "request.harness_session_id")?;
58        }
59        if let Some(s) = &self.harness_run_id {
60            require_non_empty(s, "request.harness_run_id")?;
61        }
62        if let Some(s) = &self.harness_task_id {
63            require_non_empty(s, "request.harness_task_id")?;
64        }
65        if let Some(s) = &self.capability_snapshot_ref {
66            require_non_empty(s, "request.capability_snapshot_ref")?;
67        }
68        if let Some(s) = &self.idempotency_key {
69            require_non_empty(s, "request.idempotency_key")?;
70        }
71        if let Some(fc) = &self.frame_context {
72            fc.validate()?;
73        }
74        for r in &self.payload_refs {
75            r.validate()?;
76        }
77        match self.event {
78            LifecycleEventKind::FrameOpening
79            | LifecycleEventKind::FrameOpened
80            | LifecycleEventKind::FrameEnding
81            | LifecycleEventKind::FrameEnded
82                if self.frame_context.is_none() =>
83            {
84                Err(ValidationError::InvalidRequest(
85                    "frame.* events require frame_context".into(),
86                ))
87            }
88            LifecycleEventKind::ReceiptEmitted if self.idempotency_key.is_some() => {
89                Err(ValidationError::InvalidRequest(
90                    "receipt.emitted is a notification event and must not carry an idempotency_key"
91                        .into(),
92                ))
93            }
94            _ => Ok(()),
95        }
96    }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct CallbackResponse {
102    pub schema_version: String,
103    pub status: ReceiptStatus,
104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
105    pub client_payloads: Vec<PayloadEnvelope>,
106    #[serde(default, skip_serializing_if = "Vec::is_empty")]
107    pub receipt_refs: Vec<String>,
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub warnings: Vec<Warning>,
110    pub failure_class: Option<FailureClass>,
111    pub retry_class: Option<RetryClass>,
112    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
113    pub metadata: serde_json::Map<String, serde_json::Value>,
114}
115
116impl CallbackResponse {
117    pub fn ok(status: ReceiptStatus) -> Self {
118        Self {
119            schema_version: SCHEMA_VERSION.to_string(),
120            status,
121            client_payloads: Vec::new(),
122            receipt_refs: Vec::new(),
123            warnings: Vec::new(),
124            failure_class: None,
125            retry_class: None,
126            metadata: serde_json::Map::new(),
127        }
128    }
129
130    pub fn failed(failure: FailureClass) -> Self {
131        Self {
132            schema_version: SCHEMA_VERSION.to_string(),
133            status: ReceiptStatus::Failed,
134            client_payloads: Vec::new(),
135            receipt_refs: Vec::new(),
136            warnings: Vec::new(),
137            failure_class: Some(failure),
138            retry_class: Some(failure.default_retry()),
139            metadata: serde_json::Map::new(),
140        }
141    }
142
143    pub fn validate(&self) -> Result<(), ValidationError> {
144        if self.schema_version != SCHEMA_VERSION {
145            return Err(ValidationError::SchemaVersionMismatch {
146                expected: SCHEMA_VERSION.to_string(),
147                found: self.schema_version.clone(),
148            });
149        }
150        for p in &self.client_payloads {
151            p.validate()?;
152        }
153        for r in &self.receipt_refs {
154            require_non_empty(r, "response.receipt_refs[]")?;
155        }
156        for w in &self.warnings {
157            w.validate()?;
158        }
159        match (
160            matches!(self.status, ReceiptStatus::Failed),
161            self.failure_class.is_some(),
162        ) {
163            (true, false) => {
164                return Err(ValidationError::InvalidResponse(
165                    "status=failed requires failure_class".into(),
166                ));
167            }
168            (false, true) => {
169                return Err(ValidationError::InvalidResponse(
170                    "failure_class is only valid on status=failed responses".into(),
171                ));
172            }
173            _ => {}
174        }
175        if matches!(self.status, ReceiptStatus::Failed) && self.retry_class.is_none() {
176            return Err(ValidationError::InvalidResponse(
177                "status=failed requires retry_class (clients must declare retry posture)".into(),
178            ));
179        }
180        Ok(())
181    }
182}
183
184// ============================================================================
185// Dispatch envelope (transport boundary)
186// ============================================================================
187
188/// Wire shape carrying a [`CallbackRequest`] and the opaque
189/// [`PayloadEnvelope`] bodies a dispatch is delivering with.
190///
191/// The lifecycle contract distinguishes two concerns:
192///
193/// * the *request* a client receives describing what is happening
194///   (event kind, frame context, [`CallbackRequest::payload_refs`]
195///   pointing at named/sized/digested payloads), and
196/// * the *envelope bodies* the request refers to.
197///
198/// Until issue #22 the CLI and the subprocess invoker only transported
199/// the request — the envelopes were not delivered, so subprocess clients
200/// could not reach payload bodies and negotiation never saw real
201/// placement inputs. `DispatchEnvelope` is the transport-boundary shape
202/// that carries both:
203///
204/// ```json
205/// {
206///   "schema_version": "lifeloop.v0.2",
207///   "request": { "...CallbackRequest...": "..." },
208///   "payloads": [ { "...PayloadEnvelope...": "..." } ]
209/// }
210/// ```
211///
212/// Lifeloop does not parse `payloads[].body` — it is transported
213/// verbatim, consistent with the spec rule that bodies are opaque
214/// (`docs/specs/lifecycle-contract/body.md`, "Opaque Payload Envelope").
215/// `payloads` is omitted on the wire when empty.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(deny_unknown_fields)]
218pub struct DispatchEnvelope {
219    pub schema_version: String,
220    pub request: CallbackRequest,
221    #[serde(default, skip_serializing_if = "Vec::is_empty")]
222    pub payloads: Vec<PayloadEnvelope>,
223}
224
225impl DispatchEnvelope {
226    /// Construct a dispatch envelope at the canonical schema version.
227    pub fn new(request: CallbackRequest, payloads: Vec<PayloadEnvelope>) -> Self {
228        Self {
229            schema_version: SCHEMA_VERSION.to_string(),
230            request,
231            payloads,
232        }
233    }
234
235    /// Validate the envelope: schema version, the inner request, and
236    /// each carried payload. Cross-correlation between
237    /// `request.payload_refs` and `payloads[]` is intentionally *not*
238    /// enforced here — a request may declare refs that are delivered
239    /// out-of-band, and clients may receive bodies the request did not
240    /// list (e.g. degraded fallback bodies). Cross-correlation belongs
241    /// in negotiation/receipt synthesis, not the transport boundary.
242    pub fn validate(&self) -> Result<(), ValidationError> {
243        if self.schema_version != SCHEMA_VERSION {
244            return Err(ValidationError::SchemaVersionMismatch {
245                expected: SCHEMA_VERSION.to_string(),
246                found: self.schema_version.clone(),
247            });
248        }
249        self.request.validate()?;
250        for p in &self.payloads {
251            p.validate()?;
252        }
253        Ok(())
254    }
255}