Skip to main content

lifeloop/router/
plan.rs

1//! Routing plan synthesis: turn a validated [`CallbackRequest`] plus an
2//! [`AdapterRegistry`] resolution into a typed [`RoutingPlan`] downstream
3//! stages can dispatch from.
4//!
5//! The plan is the single hand-off shape between this module and the
6//! follow-up router issues (negotiation, callback invocation, receipt
7//! emission, failure mapping). It is a typed struct, not a JSON blob,
8//! and it preserves opaque payload references — the router never
9//! inspects payload body semantics.
10
11use crate::{
12    AdapterManifest, CallbackRequest, FrameContext, IntegrationMode, LifecycleEventKind,
13    PayloadRef, SCHEMA_VERSION,
14};
15
16use super::validation::{AdapterRegistry, AdapterResolution, RouteError, manifest_of};
17
18/// Pre-dispatch routing plan produced by [`route`].
19///
20/// Holds only data downstream stages need. Carries a *clone* of the
21/// resolved [`AdapterManifest`] so the plan is `'static`-friendly —
22/// downstream stages may persist or hand it across threads without
23/// being tied to the registry's lifetime.
24///
25/// The plan preserves [`PayloadRef`]s exactly as received. The router
26/// does not transform them. Issue #3's renderer will consume them
27/// alongside the lifecycle event, adapter identity, integration mode,
28/// frame context, and (when present) payload envelopes.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct RoutingPlan {
31    /// The lifecycle event kind being routed.
32    pub event: LifecycleEventKind,
33    /// Caller-supplied event id (already validated non-empty).
34    pub event_id: String,
35    /// Caller-supplied invocation id (already validated non-empty).
36    pub invocation_id: String,
37    /// Resolved adapter manifest. Both `adapter_id` and
38    /// `adapter_version` matched the request.
39    pub adapter: AdapterManifest,
40    /// Integration mode the caller declared on the request.
41    /// Negotiation against `adapter.integration_modes` is a follow-up
42    /// router issue; this skeleton preserves the declared mode
43    /// verbatim.
44    pub integration_mode: IntegrationMode,
45    /// Optional harness session identity from the request.
46    pub harness_session_id: Option<String>,
47    /// Optional harness run identity from the request.
48    pub harness_run_id: Option<String>,
49    /// Optional harness task identity from the request.
50    pub harness_task_id: Option<String>,
51    /// Frame context, when supplied. Already validated.
52    pub frame_context: Option<FrameContext>,
53    /// Opaque payload references, in the order the caller supplied
54    /// them. The router does not inspect or reorder them.
55    pub payload_refs: Vec<PayloadRef>,
56    /// Optional capability-snapshot reference; opaque to the router.
57    pub capability_snapshot_ref: Option<String>,
58    /// Optional sequence number from the request.
59    pub sequence: Option<u64>,
60    /// Optional idempotency key from the request.
61    pub idempotency_key: Option<String>,
62    /// Opaque caller metadata. Preserved verbatim for callback dispatch.
63    pub metadata: serde_json::Map<String, serde_json::Value>,
64}
65
66/// Validate a [`CallbackRequest`] and resolve its adapter against the
67/// supplied [`AdapterRegistry`], producing a [`RoutingPlan`].
68///
69/// Validation order is: schema version → required non-empty
70/// identifiers → frame-context invariants → event-envelope semantics
71/// → payload-ref structure → adapter resolution. Each failure short-
72/// circuits with the matching [`RouteError`] variant.
73///
74/// The router does not invoke callbacks, persist receipts, or
75/// negotiate capabilities — see the [`super::CallbackInvoker`] and
76/// [`super::ReceiptEmitter`] seams, and the [`super::negotiate`]
77/// function, for those follow-up stages.
78pub fn route<R: AdapterRegistry>(
79    req: &CallbackRequest,
80    registry: &R,
81) -> Result<RoutingPlan, RouteError> {
82    // Schema version.
83    if req.schema_version != SCHEMA_VERSION {
84        return Err(RouteError::SchemaVersionMismatch {
85            expected: SCHEMA_VERSION.to_string(),
86            found: req.schema_version.clone(),
87        });
88    }
89
90    // Non-empty sentinel checks. The set mirrors `CallbackRequest::validate`
91    // so the router and the wire validator agree on which identifiers
92    // are required-non-empty. Optional fields are checked for
93    // non-emptiness only when present.
94    require_non_empty(&req.event_id, "request.event_id")?;
95    require_non_empty(&req.adapter_id, "request.adapter_id")?;
96    require_non_empty(&req.adapter_version, "request.adapter_version")?;
97    require_non_empty(&req.invocation_id, "request.invocation_id")?;
98    if let Some(s) = &req.harness_session_id {
99        require_non_empty(s, "request.harness_session_id")?;
100    }
101    if let Some(s) = &req.harness_run_id {
102        require_non_empty(s, "request.harness_run_id")?;
103    }
104    if let Some(s) = &req.harness_task_id {
105        require_non_empty(s, "request.harness_task_id")?;
106    }
107    if let Some(s) = &req.capability_snapshot_ref {
108        require_non_empty(s, "request.capability_snapshot_ref")?;
109    }
110    if let Some(s) = &req.idempotency_key {
111        require_non_empty(s, "request.idempotency_key")?;
112    }
113
114    // Frame context invariants.
115    if let Some(fc) = &req.frame_context {
116        validate_frame_context(fc)?;
117    }
118    require_frame_context_for_event(req)?;
119
120    // Event-envelope semantics that aren't frame-context related.
121    if matches!(req.event, LifecycleEventKind::ReceiptEmitted) && req.idempotency_key.is_some() {
122        return Err(RouteError::InvalidEventEnvelope {
123            detail: "receipt.emitted is a notification event and must not carry \
124                     an idempotency_key"
125                .into(),
126        });
127    }
128
129    // Payload reference structure (opaque body — only sentinel checks).
130    for (idx, r) in req.payload_refs.iter().enumerate() {
131        if r.payload_id.is_empty() {
132            return Err(RouteError::InvalidPayloadRef {
133                index: idx,
134                detail: "payload_ref.payload_id is empty".into(),
135            });
136        }
137        if r.payload_kind.is_empty() {
138            return Err(RouteError::InvalidPayloadRef {
139                index: idx,
140                detail: "payload_ref.payload_kind is empty".into(),
141            });
142        }
143    }
144
145    // Adapter resolution: id and version are distinct failure classes.
146    let resolution = registry.resolve(&req.adapter_id, &req.adapter_version);
147    let manifest = match &resolution {
148        AdapterResolution::Found(_) => manifest_of(&resolution).expect("Found carries manifest"),
149        AdapterResolution::UnknownId => {
150            return Err(RouteError::AdapterIdNotFound {
151                adapter_id: req.adapter_id.clone(),
152            });
153        }
154        AdapterResolution::VersionMismatch { registered_version } => {
155            return Err(RouteError::AdapterVersionMismatch {
156                adapter_id: req.adapter_id.clone(),
157                requested: req.adapter_version.clone(),
158                registered: registered_version.clone(),
159            });
160        }
161    };
162
163    Ok(RoutingPlan {
164        event: req.event,
165        event_id: req.event_id.clone(),
166        invocation_id: req.invocation_id.clone(),
167        adapter: manifest.clone(),
168        integration_mode: req.integration_mode,
169        harness_session_id: req.harness_session_id.clone(),
170        harness_run_id: req.harness_run_id.clone(),
171        harness_task_id: req.harness_task_id.clone(),
172        frame_context: req.frame_context.clone(),
173        payload_refs: req.payload_refs.clone(),
174        capability_snapshot_ref: req.capability_snapshot_ref.clone(),
175        sequence: req.sequence,
176        idempotency_key: req.idempotency_key.clone(),
177        metadata: req.metadata.clone(),
178    })
179}
180
181fn require_non_empty(value: &str, field: &'static str) -> Result<(), RouteError> {
182    if value.is_empty() {
183        Err(RouteError::EmptySentinel { field })
184    } else {
185        Ok(())
186    }
187}
188
189/// Frame-context structural invariants in one place.
190///
191/// Catches:
192/// * empty `frame_id` on a populated frame_context;
193/// * empty `parent_frame_id` when supplied;
194/// * `frame_class=top_level` carrying a `parent_frame_id`;
195/// * `frame_class=subcall` missing `parent_frame_id`.
196///
197/// `FrameContext` is a typed struct so "frame_class missing entirely"
198/// is impossible at this layer — serde rejects an absent
199/// `frame_class` at deserialize time. The acceptance criterion's
200/// "any frame field set without frame_class" case is therefore
201/// caught at the wire boundary; we still note it here so a future
202/// loosely-typed entry point routes through the same predicate.
203fn validate_frame_context(fc: &FrameContext) -> Result<(), RouteError> {
204    if fc.frame_id.is_empty() {
205        return Err(RouteError::InvalidFrameContext {
206            detail: "frame_id is empty".into(),
207        });
208    }
209    if let Some(parent) = &fc.parent_frame_id
210        && parent.is_empty()
211    {
212        return Err(RouteError::InvalidFrameContext {
213            detail: "parent_frame_id is empty".into(),
214        });
215    }
216    match (fc.frame_class, &fc.parent_frame_id) {
217        (crate::FrameClass::TopLevel, Some(_)) => Err(RouteError::InvalidFrameContext {
218            detail: "frame_class=top_level must not carry parent_frame_id".into(),
219        }),
220        (crate::FrameClass::Subcall, None) => Err(RouteError::InvalidFrameContext {
221            detail: "frame_class=subcall requires parent_frame_id".into(),
222        }),
223        _ => Ok(()),
224    }
225}
226
227fn require_frame_context_for_event(req: &CallbackRequest) -> Result<(), RouteError> {
228    let needs_frame = matches!(
229        req.event,
230        LifecycleEventKind::FrameOpening
231            | LifecycleEventKind::FrameOpened
232            | LifecycleEventKind::FrameEnding
233            | LifecycleEventKind::FrameEnded
234    );
235    if needs_frame && req.frame_context.is_none() {
236        return Err(RouteError::InvalidFrameContext {
237            detail: "frame.* events require frame_context".into(),
238        });
239    }
240    Ok(())
241}