Skip to main content

lifeloop/router/
validation.rs

1//! Typed pre-dispatch validation errors and adapter registry abstraction.
2//!
3//! Each variant of [`RouteError`] corresponds to one failure class named
4//! in issue #7's acceptance criteria, so a downstream
5//! [`super::FailureMapper`] can produce a deterministic
6//! `failure_class` on the lifecycle receipt without re-inspecting the
7//! request.
8
9use crate::{AdapterManifest, RegisteredAdapter, lookup_manifest};
10
11/// Reasons the router refused a [`crate::CallbackRequest`] before dispatch.
12///
13/// Variants are intentionally fine-grained: each acceptance-criterion
14/// failure class gets its own variant so a future
15/// [`super::FailureMapper`] can map them onto distinct
16/// [`crate::FailureClass`] values without inspecting strings.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum RouteError {
19    /// `schema_version` on the inbound request did not match the
20    /// router's compiled-in [`crate::SCHEMA_VERSION`].
21    SchemaVersionMismatch { expected: String, found: String },
22
23    /// A required identifier was the empty sentinel string. Carries
24    /// the field path for diagnostics.
25    EmptySentinel { field: &'static str },
26
27    /// The deserialized request carried an event-kind value that is
28    /// not part of the wire vocabulary.
29    ///
30    /// In practice serde rejects unknown enum wire names at
31    /// deserialize time, but the variant exists so non-serde call
32    /// sites (e.g. a future text-protocol bridge) can surface the
33    /// same failure class without inventing a parallel error type.
34    UnknownEventName { received: String },
35
36    /// The deserialized request carried an enum value other than the
37    /// event kind (e.g. `integration_mode`) whose wire name is not in
38    /// the vocabulary. Preserved as a separate variant from
39    /// [`Self::UnknownEventName`] so the failure class is unambiguous
40    /// when mapping to a receipt.
41    UnknownEnumName {
42        field: &'static str,
43        received: String,
44    },
45
46    /// `frame_context` violated a structural invariant: required for
47    /// the event but absent, or partially populated (e.g.
48    /// `parent_frame_id` without `frame_id`, or any frame field set
49    /// without `frame_class`).
50    InvalidFrameContext { detail: String },
51
52    /// A payload reference failed structural validation (empty
53    /// `payload_id` / `payload_kind`). Body semantics are opaque
54    /// to the router — this only catches sentinel-empty fields on
55    /// the reference itself.
56    InvalidPayloadRef { index: usize, detail: String },
57
58    /// The request's `event` is structurally illegal for this
59    /// envelope (e.g. `receipt.emitted` carrying an
60    /// `idempotency_key`). Distinct from frame-context errors so the
61    /// failure class is unambiguous.
62    InvalidEventEnvelope { detail: String },
63
64    /// No registered adapter has an `adapter_id` matching the
65    /// request. Distinct from [`Self::AdapterVersionMismatch`].
66    AdapterIdNotFound { adapter_id: String },
67
68    /// An adapter with the requested `adapter_id` is registered, but
69    /// its `adapter_version` does not match the request. Carries
70    /// both versions so a client can surface a precise upgrade
71    /// suggestion.
72    AdapterVersionMismatch {
73        adapter_id: String,
74        requested: String,
75        registered: String,
76    },
77}
78
79impl std::fmt::Display for RouteError {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            Self::SchemaVersionMismatch { expected, found } => write!(
83                f,
84                "schema_version mismatch: expected `{expected}`, found `{found}`"
85            ),
86            Self::EmptySentinel { field } => {
87                write!(f, "empty sentinel string in field `{field}`")
88            }
89            Self::UnknownEventName { received } => {
90                write!(f, "unknown lifecycle event wire name `{received}`")
91            }
92            Self::UnknownEnumName { field, received } => {
93                write!(f, "unknown enum wire name `{received}` for field `{field}`")
94            }
95            Self::InvalidFrameContext { detail } => {
96                write!(f, "invalid frame_context: {detail}")
97            }
98            Self::InvalidPayloadRef { index, detail } => {
99                write!(f, "invalid payload_refs[{index}]: {detail}")
100            }
101            Self::InvalidEventEnvelope { detail } => {
102                write!(f, "invalid event envelope: {detail}")
103            }
104            Self::AdapterIdNotFound { adapter_id } => {
105                write!(f, "no registered adapter with id `{adapter_id}`")
106            }
107            Self::AdapterVersionMismatch {
108                adapter_id,
109                requested,
110                registered,
111            } => write!(
112                f,
113                "adapter `{adapter_id}` is registered at version `{registered}`, \
114                 request asked for `{requested}`"
115            ),
116        }
117    }
118}
119
120impl std::error::Error for RouteError {}
121
122/// Resolution outcome for an `(adapter_id, adapter_version)` pair.
123///
124/// Distinguishes "no such adapter" from "wrong version of a known
125/// adapter" so the router can return precise [`RouteError`]s
126/// without the registry implementation having to know about
127/// [`RouteError`] itself.
128// `Found` carries a full `RegisteredAdapter` (~296 bytes) while the other
129// variants are tiny. The size asymmetry is intentional: resolution is a
130// short-lived stack value, the registry yields one at a time, and boxing
131// would force every consumer (including the public `AdapterRegistry` trait
132// impls) through an indirection that adds no value at this scale.
133#[allow(clippy::large_enum_variant)]
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum AdapterResolution {
136    /// Adapter found and version matches.
137    Found(RegisteredAdapter),
138    /// Adapter id is unknown.
139    UnknownId,
140    /// Adapter id is known but the registered version is not the one
141    /// the request asked for. Carries the registered version so the
142    /// router can name both sides in the error.
143    VersionMismatch { registered_version: String },
144}
145
146/// Source of [`AdapterManifest`]s the router consults at dispatch time.
147///
148/// Implemented for the built-in [`crate::manifest_registry`] via
149/// [`BuiltinAdapterRegistry`]. Tests pass a fixture-style fake registry
150/// that returns a synthetic manifest, which is how the router exercises
151/// resolution against a fake adapter manifest without depending on any
152/// specific lifecycle client.
153pub trait AdapterRegistry {
154    /// Resolve `(adapter_id, adapter_version)`. Implementations MUST
155    /// distinguish "id unknown" from "id known, version mismatch" —
156    /// the router needs both signals to produce a typed
157    /// [`RouteError`].
158    fn resolve(&self, adapter_id: &str, adapter_version: &str) -> AdapterResolution;
159}
160
161/// Adapter registry backed by [`crate::manifest_registry`].
162///
163/// Cheap to construct; holds no state. Each `resolve` call walks the
164/// built-in registry. The registry is small (≈6 adapters) so a linear
165/// scan is fine for the skeleton; a later issue may swap in a hash
166/// index without changing this trait.
167#[derive(Debug, Default, Clone, Copy)]
168pub struct BuiltinAdapterRegistry;
169
170impl AdapterRegistry for BuiltinAdapterRegistry {
171    fn resolve(&self, adapter_id: &str, adapter_version: &str) -> AdapterResolution {
172        match lookup_manifest(adapter_id) {
173            None => AdapterResolution::UnknownId,
174            Some(entry) => {
175                if entry.manifest.adapter_version == adapter_version {
176                    AdapterResolution::Found(entry)
177                } else {
178                    AdapterResolution::VersionMismatch {
179                        registered_version: entry.manifest.adapter_version.clone(),
180                    }
181                }
182            }
183        }
184    }
185}
186
187/// Borrow the manifest out of a resolution result. Used by the plan
188/// stage; kept here so the public type hierarchy stays minimal.
189pub(crate) fn manifest_of(resolution: &AdapterResolution) -> Option<&AdapterManifest> {
190    match resolution {
191        AdapterResolution::Found(entry) => Some(&entry.manifest),
192        _ => None,
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn builtin_registry_resolves_codex_at_known_version() {
202        let reg = BuiltinAdapterRegistry;
203        let r = reg.resolve("codex", "0.1.0");
204        assert!(matches!(r, AdapterResolution::Found(_)));
205    }
206
207    #[test]
208    fn builtin_registry_distinguishes_unknown_id_from_version_mismatch() {
209        let reg = BuiltinAdapterRegistry;
210        assert_eq!(
211            reg.resolve("nonexistent", "0.1.0"),
212            AdapterResolution::UnknownId
213        );
214        match reg.resolve("codex", "9.9.9") {
215            AdapterResolution::VersionMismatch { registered_version } => {
216                assert_eq!(registered_version, "0.1.0");
217            }
218            other => panic!("expected VersionMismatch, got {other:?}"),
219        }
220    }
221}