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    /// Frame context, when supplied. Already validated.
46    pub frame_context: Option<FrameContext>,
47    /// Opaque payload references, in the order the caller supplied
48    /// them. The router does not inspect or reorder them.
49    pub payload_refs: Vec<PayloadRef>,
50    /// Optional capability-snapshot reference; opaque to the router.
51    pub capability_snapshot_ref: Option<String>,
52    /// Optional sequence number from the request.
53    pub sequence: Option<u64>,
54    /// Optional idempotency key from the request.
55    pub idempotency_key: Option<String>,
56}
57
58/// Validate a [`CallbackRequest`] and resolve its adapter against the
59/// supplied [`AdapterRegistry`], producing a [`RoutingPlan`].
60///
61/// Validation order is: schema version → required non-empty
62/// identifiers → frame-context invariants → event-envelope semantics
63/// → payload-ref structure → adapter resolution. Each failure short-
64/// circuits with the matching [`RouteError`] variant.
65///
66/// The router does not invoke callbacks, persist receipts, or
67/// negotiate capabilities — see the [`super::CallbackInvoker`],
68/// [`super::ReceiptEmitter`], and [`super::NegotiationStrategy`]
69/// seams for those follow-up stages.
70pub fn route<R: AdapterRegistry>(
71    req: &CallbackRequest,
72    registry: &R,
73) -> Result<RoutingPlan, RouteError> {
74    // Schema version.
75    if req.schema_version != SCHEMA_VERSION {
76        return Err(RouteError::SchemaVersionMismatch {
77            expected: SCHEMA_VERSION.to_string(),
78            found: req.schema_version.clone(),
79        });
80    }
81
82    // Non-empty sentinel checks. The set mirrors `CallbackRequest::validate`
83    // so the router and the wire validator agree on which identifiers
84    // are required-non-empty. Optional fields are checked for
85    // non-emptiness only when present.
86    require_non_empty(&req.event_id, "request.event_id")?;
87    require_non_empty(&req.adapter_id, "request.adapter_id")?;
88    require_non_empty(&req.adapter_version, "request.adapter_version")?;
89    require_non_empty(&req.invocation_id, "request.invocation_id")?;
90    if let Some(s) = &req.harness_session_id {
91        require_non_empty(s, "request.harness_session_id")?;
92    }
93    if let Some(s) = &req.harness_run_id {
94        require_non_empty(s, "request.harness_run_id")?;
95    }
96    if let Some(s) = &req.harness_task_id {
97        require_non_empty(s, "request.harness_task_id")?;
98    }
99    if let Some(s) = &req.capability_snapshot_ref {
100        require_non_empty(s, "request.capability_snapshot_ref")?;
101    }
102    if let Some(s) = &req.idempotency_key {
103        require_non_empty(s, "request.idempotency_key")?;
104    }
105
106    // Frame context invariants.
107    if let Some(fc) = &req.frame_context {
108        validate_frame_context(fc)?;
109    }
110    require_frame_context_for_event(req)?;
111
112    // Event-envelope semantics that aren't frame-context related.
113    if matches!(req.event, LifecycleEventKind::ReceiptEmitted) && req.idempotency_key.is_some() {
114        return Err(RouteError::InvalidEventEnvelope {
115            detail: "receipt.emitted is a notification event and must not carry \
116                     an idempotency_key"
117                .into(),
118        });
119    }
120
121    // Payload reference structure (opaque body — only sentinel checks).
122    for (idx, r) in req.payload_refs.iter().enumerate() {
123        if r.payload_id.is_empty() {
124            return Err(RouteError::InvalidPayloadRef {
125                index: idx,
126                detail: "payload_ref.payload_id is empty".into(),
127            });
128        }
129        if r.payload_kind.is_empty() {
130            return Err(RouteError::InvalidPayloadRef {
131                index: idx,
132                detail: "payload_ref.payload_kind is empty".into(),
133            });
134        }
135    }
136
137    // Adapter resolution: id and version are distinct failure classes.
138    let resolution = registry.resolve(&req.adapter_id, &req.adapter_version);
139    let manifest = match &resolution {
140        AdapterResolution::Found(_) => manifest_of(&resolution).expect("Found carries manifest"),
141        AdapterResolution::UnknownId => {
142            return Err(RouteError::AdapterIdNotFound {
143                adapter_id: req.adapter_id.clone(),
144            });
145        }
146        AdapterResolution::VersionMismatch { registered_version } => {
147            return Err(RouteError::AdapterVersionMismatch {
148                adapter_id: req.adapter_id.clone(),
149                requested: req.adapter_version.clone(),
150                registered: registered_version.clone(),
151            });
152        }
153    };
154
155    Ok(RoutingPlan {
156        event: req.event,
157        event_id: req.event_id.clone(),
158        invocation_id: req.invocation_id.clone(),
159        adapter: manifest.clone(),
160        integration_mode: req.integration_mode,
161        frame_context: req.frame_context.clone(),
162        payload_refs: req.payload_refs.clone(),
163        capability_snapshot_ref: req.capability_snapshot_ref.clone(),
164        sequence: req.sequence,
165        idempotency_key: req.idempotency_key.clone(),
166    })
167}
168
169fn require_non_empty(value: &str, field: &'static str) -> Result<(), RouteError> {
170    if value.is_empty() {
171        Err(RouteError::EmptySentinel { field })
172    } else {
173        Ok(())
174    }
175}
176
177/// Frame-context structural invariants in one place.
178///
179/// Catches:
180/// * empty `frame_id` on a populated frame_context;
181/// * empty `parent_frame_id` when supplied;
182/// * `frame_class=top_level` carrying a `parent_frame_id`;
183/// * `frame_class=subcall` missing `parent_frame_id`.
184///
185/// `FrameContext` is a typed struct so "frame_class missing entirely"
186/// is impossible at this layer — serde rejects an absent
187/// `frame_class` at deserialize time. The acceptance criterion's
188/// "any frame field set without frame_class" case is therefore
189/// caught at the wire boundary; we still note it here so a future
190/// loosely-typed entry point routes through the same predicate.
191fn validate_frame_context(fc: &FrameContext) -> Result<(), RouteError> {
192    if fc.frame_id.is_empty() {
193        return Err(RouteError::InvalidFrameContext {
194            detail: "frame_id is empty".into(),
195        });
196    }
197    if let Some(parent) = &fc.parent_frame_id
198        && parent.is_empty()
199    {
200        return Err(RouteError::InvalidFrameContext {
201            detail: "parent_frame_id is empty".into(),
202        });
203    }
204    match (fc.frame_class, &fc.parent_frame_id) {
205        (crate::FrameClass::TopLevel, Some(_)) => Err(RouteError::InvalidFrameContext {
206            detail: "frame_class=top_level must not carry parent_frame_id".into(),
207        }),
208        (crate::FrameClass::Subcall, None) => Err(RouteError::InvalidFrameContext {
209            detail: "frame_class=subcall requires parent_frame_id".into(),
210        }),
211        _ => Ok(()),
212    }
213}
214
215fn require_frame_context_for_event(req: &CallbackRequest) -> Result<(), RouteError> {
216    let needs_frame = matches!(
217        req.event,
218        LifecycleEventKind::FrameOpening
219            | LifecycleEventKind::FrameOpened
220            | LifecycleEventKind::FrameEnding
221            | LifecycleEventKind::FrameEnded
222    );
223    if needs_frame && req.frame_context.is_none() {
224        return Err(RouteError::InvalidFrameContext {
225            detail: "frame.* events require frame_context".into(),
226        });
227    }
228    Ok(())
229}