lifeloop-cli 0.1.1

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Routing plan synthesis: turn a validated [`CallbackRequest`] plus an
//! [`AdapterRegistry`] resolution into a typed [`RoutingPlan`] downstream
//! stages can dispatch from.
//!
//! The plan is the single hand-off shape between this module and the
//! follow-up router issues (negotiation, callback invocation, receipt
//! emission, failure mapping). It is a typed struct, not a JSON blob,
//! and it preserves opaque payload references — the router never
//! inspects payload body semantics.

use crate::{
    AdapterManifest, CallbackRequest, FrameContext, IntegrationMode, LifecycleEventKind,
    PayloadRef, SCHEMA_VERSION,
};

use super::validation::{AdapterRegistry, AdapterResolution, RouteError, manifest_of};

/// Pre-dispatch routing plan produced by [`route`].
///
/// Holds only data downstream stages need. Carries a *clone* of the
/// resolved [`AdapterManifest`] so the plan is `'static`-friendly —
/// downstream stages may persist or hand it across threads without
/// being tied to the registry's lifetime.
///
/// The plan preserves [`PayloadRef`]s exactly as received. The router
/// does not transform them. Issue #3's renderer will consume them
/// alongside the lifecycle event, adapter identity, integration mode,
/// frame context, and (when present) payload envelopes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoutingPlan {
    /// The lifecycle event kind being routed.
    pub event: LifecycleEventKind,
    /// Caller-supplied event id (already validated non-empty).
    pub event_id: String,
    /// Caller-supplied invocation id (already validated non-empty).
    pub invocation_id: String,
    /// Resolved adapter manifest. Both `adapter_id` and
    /// `adapter_version` matched the request.
    pub adapter: AdapterManifest,
    /// Integration mode the caller declared on the request.
    /// Negotiation against `adapter.integration_modes` is a follow-up
    /// router issue; this skeleton preserves the declared mode
    /// verbatim.
    pub integration_mode: IntegrationMode,
    /// Frame context, when supplied. Already validated.
    pub frame_context: Option<FrameContext>,
    /// Opaque payload references, in the order the caller supplied
    /// them. The router does not inspect or reorder them.
    pub payload_refs: Vec<PayloadRef>,
    /// Optional capability-snapshot reference; opaque to the router.
    pub capability_snapshot_ref: Option<String>,
    /// Optional sequence number from the request.
    pub sequence: Option<u64>,
    /// Optional idempotency key from the request.
    pub idempotency_key: Option<String>,
}

/// Validate a [`CallbackRequest`] and resolve its adapter against the
/// supplied [`AdapterRegistry`], producing a [`RoutingPlan`].
///
/// Validation order is: schema version → required non-empty
/// identifiers → frame-context invariants → event-envelope semantics
/// → payload-ref structure → adapter resolution. Each failure short-
/// circuits with the matching [`RouteError`] variant.
///
/// The router does not invoke callbacks, persist receipts, or
/// negotiate capabilities — see the [`super::CallbackInvoker`],
/// [`super::ReceiptEmitter`], and [`super::NegotiationStrategy`]
/// seams for those follow-up stages.
pub fn route<R: AdapterRegistry>(
    req: &CallbackRequest,
    registry: &R,
) -> Result<RoutingPlan, RouteError> {
    // Schema version.
    if req.schema_version != SCHEMA_VERSION {
        return Err(RouteError::SchemaVersionMismatch {
            expected: SCHEMA_VERSION.to_string(),
            found: req.schema_version.clone(),
        });
    }

    // Non-empty sentinel checks. The set mirrors `CallbackRequest::validate`
    // so the router and the wire validator agree on which identifiers
    // are required-non-empty. Optional fields are checked for
    // non-emptiness only when present.
    require_non_empty(&req.event_id, "request.event_id")?;
    require_non_empty(&req.adapter_id, "request.adapter_id")?;
    require_non_empty(&req.adapter_version, "request.adapter_version")?;
    require_non_empty(&req.invocation_id, "request.invocation_id")?;
    if let Some(s) = &req.harness_session_id {
        require_non_empty(s, "request.harness_session_id")?;
    }
    if let Some(s) = &req.harness_run_id {
        require_non_empty(s, "request.harness_run_id")?;
    }
    if let Some(s) = &req.harness_task_id {
        require_non_empty(s, "request.harness_task_id")?;
    }
    if let Some(s) = &req.capability_snapshot_ref {
        require_non_empty(s, "request.capability_snapshot_ref")?;
    }
    if let Some(s) = &req.idempotency_key {
        require_non_empty(s, "request.idempotency_key")?;
    }

    // Frame context invariants.
    if let Some(fc) = &req.frame_context {
        validate_frame_context(fc)?;
    }
    require_frame_context_for_event(req)?;

    // Event-envelope semantics that aren't frame-context related.
    if matches!(req.event, LifecycleEventKind::ReceiptEmitted) && req.idempotency_key.is_some() {
        return Err(RouteError::InvalidEventEnvelope {
            detail: "receipt.emitted is a notification event and must not carry \
                     an idempotency_key"
                .into(),
        });
    }

    // Payload reference structure (opaque body — only sentinel checks).
    for (idx, r) in req.payload_refs.iter().enumerate() {
        if r.payload_id.is_empty() {
            return Err(RouteError::InvalidPayloadRef {
                index: idx,
                detail: "payload_ref.payload_id is empty".into(),
            });
        }
        if r.payload_kind.is_empty() {
            return Err(RouteError::InvalidPayloadRef {
                index: idx,
                detail: "payload_ref.payload_kind is empty".into(),
            });
        }
    }

    // Adapter resolution: id and version are distinct failure classes.
    let resolution = registry.resolve(&req.adapter_id, &req.adapter_version);
    let manifest = match &resolution {
        AdapterResolution::Found(_) => manifest_of(&resolution).expect("Found carries manifest"),
        AdapterResolution::UnknownId => {
            return Err(RouteError::AdapterIdNotFound {
                adapter_id: req.adapter_id.clone(),
            });
        }
        AdapterResolution::VersionMismatch { registered_version } => {
            return Err(RouteError::AdapterVersionMismatch {
                adapter_id: req.adapter_id.clone(),
                requested: req.adapter_version.clone(),
                registered: registered_version.clone(),
            });
        }
    };

    Ok(RoutingPlan {
        event: req.event,
        event_id: req.event_id.clone(),
        invocation_id: req.invocation_id.clone(),
        adapter: manifest.clone(),
        integration_mode: req.integration_mode,
        frame_context: req.frame_context.clone(),
        payload_refs: req.payload_refs.clone(),
        capability_snapshot_ref: req.capability_snapshot_ref.clone(),
        sequence: req.sequence,
        idempotency_key: req.idempotency_key.clone(),
    })
}

fn require_non_empty(value: &str, field: &'static str) -> Result<(), RouteError> {
    if value.is_empty() {
        Err(RouteError::EmptySentinel { field })
    } else {
        Ok(())
    }
}

/// Frame-context structural invariants in one place.
///
/// Catches:
/// * empty `frame_id` on a populated frame_context;
/// * empty `parent_frame_id` when supplied;
/// * `frame_class=top_level` carrying a `parent_frame_id`;
/// * `frame_class=subcall` missing `parent_frame_id`.
///
/// `FrameContext` is a typed struct so "frame_class missing entirely"
/// is impossible at this layer — serde rejects an absent
/// `frame_class` at deserialize time. The acceptance criterion's
/// "any frame field set without frame_class" case is therefore
/// caught at the wire boundary; we still note it here so a future
/// loosely-typed entry point routes through the same predicate.
fn validate_frame_context(fc: &FrameContext) -> Result<(), RouteError> {
    if fc.frame_id.is_empty() {
        return Err(RouteError::InvalidFrameContext {
            detail: "frame_id is empty".into(),
        });
    }
    if let Some(parent) = &fc.parent_frame_id
        && parent.is_empty()
    {
        return Err(RouteError::InvalidFrameContext {
            detail: "parent_frame_id is empty".into(),
        });
    }
    match (fc.frame_class, &fc.parent_frame_id) {
        (crate::FrameClass::TopLevel, Some(_)) => Err(RouteError::InvalidFrameContext {
            detail: "frame_class=top_level must not carry parent_frame_id".into(),
        }),
        (crate::FrameClass::Subcall, None) => Err(RouteError::InvalidFrameContext {
            detail: "frame_class=subcall requires parent_frame_id".into(),
        }),
        _ => Ok(()),
    }
}

fn require_frame_context_for_event(req: &CallbackRequest) -> Result<(), RouteError> {
    let needs_frame = matches!(
        req.event,
        LifecycleEventKind::FrameOpening
            | LifecycleEventKind::FrameOpened
            | LifecycleEventKind::FrameEnding
            | LifecycleEventKind::FrameEnded
    );
    if needs_frame && req.frame_context.is_none() {
        return Err(RouteError::InvalidFrameContext {
            detail: "frame.* events require frame_context".into(),
        });
    }
    Ok(())
}