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}